first commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
# pnpm resolves platform-specific optional deps (e.g. Vite's rolldown native
|
||||
# binding) reliably; npm intermittently drops them (npm/cli#4828).
|
||||
RUN corepack enable && corepack prepare pnpm@9.2.0 --activate
|
||||
COPY package.json ./
|
||||
RUN pnpm install
|
||||
CMD ["pnpm", "run", "dev"]
|
||||
@@ -0,0 +1,35 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "node_modules"] },
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Floci UI — Any Cloud. Locally. The local cloud console for Floci." />
|
||||
<title>Floci UI</title>
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem("floci-theme");
|
||||
document.documentElement.setAttribute(
|
||||
"data-theme",
|
||||
t === "light" ? "light" : "dark",
|
||||
);
|
||||
})();
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="icon" href="/src/assets/floci-black.svg" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700;800&family=Space+Mono:wght@400;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@floci/frontend",
|
||||
"description": "Floci UI web console — the React + Vite frontend for exploring local multi-cloud runtimes.",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test:e2e": "playwright test",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src tests --report-unused-disable-directives --max-warnings 0",
|
||||
"type-check": "tsc --noEmit && tsc --noEmit -p tsconfig.tests.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^1.22.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@testing-library/jest-dom": "7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vite": "^8.1.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {defineConfig} from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:4510',
|
||||
channel: process.env.PLAYWRIGHT_CHANNEL,
|
||||
viewport: {width: 1440, height: 1000},
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
},
|
||||
webServer: {
|
||||
command: 'pnpm exec vite --host 127.0.0.1 --port 4510 --strictPort',
|
||||
url: 'http://127.0.0.1:4510',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom'
|
||||
import {Layout} from '@/components/Layout'
|
||||
import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage'
|
||||
import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
|
||||
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
|
||||
import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout/>}>
|
||||
<Route index element={<Navigate to="/console/aws" replace/>}/>
|
||||
<Route path="/dashboard" element={<Navigate to="/console/aws" replace/>}/>
|
||||
<Route path="/console" element={<Navigate to="/console/aws" replace/>}/>
|
||||
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
|
||||
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
|
||||
<Route path="/cloud-explorer/:cloud/:service" element={<CloudExplorerPage/>}/>
|
||||
<Route path="/cloud-explorer/:cloud/:service/:resourceId/data" element={<DatabaseDataPage/>}/>
|
||||
<Route path="/secretsmanager" element={<SecretsManagerPage/>}/>
|
||||
<Route path="*" element={<Navigate to="/console/aws" replace/>}/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { emitApiRequest, type CloudProvider } from "@/telemetry";
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export interface HttpClientConfig {
|
||||
baseUrl?: string;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
cache?: RequestCache;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface RequestTelemetry {
|
||||
provider?: CloudProvider;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
export type EndpointRegistry = Map<string, EndpointDefinition>;
|
||||
|
||||
export interface RequestOptions<TBody = unknown>
|
||||
extends Omit<RequestInit, "body" | "method"> {
|
||||
params?: Record<string, string | number | boolean | undefined>;
|
||||
body?: TBody;
|
||||
rawBody?: BodyInit;
|
||||
cache?: RequestCache;
|
||||
timeout?: number;
|
||||
baseUrlOverride?: string;
|
||||
telemetry?: RequestTelemetry;
|
||||
}
|
||||
|
||||
export interface HttpResponse<TData> {
|
||||
data: TData;
|
||||
status: number;
|
||||
headers: Headers;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly statusText: string,
|
||||
public readonly body: unknown,
|
||||
public readonly url: string,
|
||||
) {
|
||||
super(httpErrorMessage(status, statusText, body, url));
|
||||
this.name = "HttpError";
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly url: string,
|
||||
public readonly cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "NetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
function httpErrorMessage(
|
||||
status: number,
|
||||
statusText: string,
|
||||
body: unknown,
|
||||
url: string,
|
||||
): string {
|
||||
const apiMessage = apiErrorMessage(body);
|
||||
if (!apiMessage) return `HTTP ${status} - ${statusText} [${url}]`;
|
||||
|
||||
const code = apiErrorCode(body);
|
||||
const suffix = code ? ` (${code})` : "";
|
||||
return `${apiMessage}${suffix} [${url}]`;
|
||||
}
|
||||
|
||||
function apiErrorMessage(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const value = (body as { message?: unknown; error?: unknown }).message ?? (body as { error?: unknown }).error;
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
|
||||
const detail = (body as { detail?: unknown }).detail;
|
||||
if (typeof detail === "string" && detail.trim() && detail.trim() !== value.trim()) {
|
||||
return `${value.trim()}: ${detail.trim()}`;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function apiErrorCode(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const value = (body as { code?: unknown }).code;
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
export class TimeoutError extends Error {
|
||||
constructor(
|
||||
public readonly url: string,
|
||||
public readonly timeoutMs: number,
|
||||
) {
|
||||
super(`Request timed out after ${timeoutMs}ms [${url}]`);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseParseError extends Error {
|
||||
constructor(
|
||||
public readonly url: string,
|
||||
public readonly cause?: unknown,
|
||||
) {
|
||||
super(`Failed to parse response [${url}]`);
|
||||
this.name = "ResponseParseError";
|
||||
}
|
||||
}
|
||||
|
||||
export type RequestInterceptor = (
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
) =>
|
||||
| Promise<{ url: string; init: RequestInit }>
|
||||
| { url: string; init: RequestInit };
|
||||
|
||||
export type ResponseInterceptor = (
|
||||
response: Response,
|
||||
) => Promise<Response> | Response;
|
||||
|
||||
export type EndpointDefaults = Omit<RequestOptions<never>, "body" | "params">;
|
||||
|
||||
export type EndpointDefinition = {
|
||||
path: string;
|
||||
method: HttpMethod;
|
||||
defaults?: Partial<EndpointDefaults>;
|
||||
baseUrlOverride?: string;
|
||||
telemetry?: RequestTelemetry;
|
||||
};
|
||||
|
||||
export class HttpClient {
|
||||
private readonly config: HttpClientConfig;
|
||||
private readonly requestInterceptors: RequestInterceptor[] = [];
|
||||
private readonly responseInterceptors: ResponseInterceptor[] = [];
|
||||
private readonly endpointRegistry: EndpointRegistry;
|
||||
|
||||
constructor(
|
||||
config: HttpClientConfig = {},
|
||||
registry: EndpointRegistry = new Map(),
|
||||
) {
|
||||
this.config = config;
|
||||
this.endpointRegistry = registry;
|
||||
}
|
||||
|
||||
addRequestInterceptor(interceptor: RequestInterceptor): this {
|
||||
this.requestInterceptors.push(interceptor);
|
||||
return this;
|
||||
}
|
||||
|
||||
addResponseInterceptor(interceptor: ResponseInterceptor): this {
|
||||
this.responseInterceptors.push(interceptor);
|
||||
return this;
|
||||
}
|
||||
|
||||
async request<TResponse, TBody = unknown>(
|
||||
method: HttpMethod,
|
||||
endpoint: string,
|
||||
options: RequestOptions<TBody> = {},
|
||||
): Promise<HttpResponse<TResponse>> {
|
||||
const started = performance.now();
|
||||
const {
|
||||
params,
|
||||
body,
|
||||
rawBody,
|
||||
timeout,
|
||||
cache,
|
||||
baseUrlOverride,
|
||||
telemetry,
|
||||
...restOptions
|
||||
} = options;
|
||||
let finalUrl = endpoint;
|
||||
let statusCode = 0;
|
||||
|
||||
try {
|
||||
const base = baseUrlOverride ?? this.config.baseUrl ?? "";
|
||||
const rawUrl = endpoint.startsWith("http")
|
||||
? endpoint
|
||||
: `${base}${endpoint}`;
|
||||
finalUrl = this.buildUrl(rawUrl, params);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
...this.config.defaultHeaders,
|
||||
...(body !== undefined && rawBody === undefined
|
||||
? { "Content-Type": "application/json" }
|
||||
: {}),
|
||||
...(restOptions.headers as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
let init: RequestInit = {
|
||||
...restOptions,
|
||||
method,
|
||||
headers,
|
||||
cache: cache ?? this.config.cache,
|
||||
...(rawBody !== undefined
|
||||
? { body: rawBody }
|
||||
: body !== undefined
|
||||
? { body: JSON.stringify(body) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
for (const interceptor of this.requestInterceptors) {
|
||||
({ url: finalUrl, init } = await interceptor(finalUrl, init));
|
||||
}
|
||||
|
||||
let response = await this.fetchWithTimeout(
|
||||
finalUrl,
|
||||
init,
|
||||
timeout ?? this.config.timeout,
|
||||
);
|
||||
statusCode = response.status;
|
||||
|
||||
for (const interceptor of this.responseInterceptors) {
|
||||
response = await interceptor(response);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw await this.createHttpError(response, finalUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
data: await this.parseResponse<TResponse>(response, finalUrl),
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
ok: response.ok,
|
||||
};
|
||||
} catch (error) {
|
||||
throw this.normalizeError(error, finalUrl, timeout ?? this.config.timeout);
|
||||
} finally {
|
||||
if (telemetry?.service && statusCode > 0) {
|
||||
emitApiRequest({
|
||||
provider: telemetry.provider ?? "aws",
|
||||
service: telemetry.service,
|
||||
method,
|
||||
path: endpoint,
|
||||
statusCode,
|
||||
latencyMs: Math.round(performance.now() - started),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async call<TResponse, TBody = unknown>(
|
||||
key: string,
|
||||
options: RequestOptions<TBody> = {},
|
||||
pathParams?: Record<string, string>,
|
||||
): Promise<HttpResponse<TResponse>> {
|
||||
const endpoint = this.endpointRegistry.get(key);
|
||||
if (!endpoint) throw new Error(`Endpoint "${key}" is not registered.`);
|
||||
|
||||
let path = endpoint.path;
|
||||
if (pathParams) {
|
||||
for (const [param, value] of Object.entries(pathParams)) {
|
||||
path = path.replace(`:${param}`, encodeURIComponent(value));
|
||||
}
|
||||
}
|
||||
|
||||
return this.request<TResponse, TBody>(endpoint.method, path, {
|
||||
...endpoint.defaults,
|
||||
...options,
|
||||
baseUrlOverride: endpoint.baseUrlOverride,
|
||||
telemetry: {
|
||||
...endpoint.telemetry,
|
||||
...options.telemetry,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get<TResponse>(endpoint: string, options?: RequestOptions<never>) {
|
||||
return this.request<TResponse, never>("GET", endpoint, options);
|
||||
}
|
||||
|
||||
post<TResponse, TBody = unknown>(
|
||||
endpoint: string,
|
||||
body?: TBody,
|
||||
options?: RequestOptions<TBody>,
|
||||
) {
|
||||
return this.request<TResponse, TBody>("POST", endpoint, {
|
||||
...options,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
put<TResponse, TBody = unknown>(
|
||||
endpoint: string,
|
||||
body?: TBody,
|
||||
options?: RequestOptions<TBody>,
|
||||
) {
|
||||
return this.request<TResponse, TBody>("PUT", endpoint, {
|
||||
...options,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
patch<TResponse, TBody = unknown>(
|
||||
endpoint: string,
|
||||
body?: TBody,
|
||||
options?: RequestOptions<TBody>,
|
||||
) {
|
||||
return this.request<TResponse, TBody>("PATCH", endpoint, {
|
||||
...options,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
delete<TResponse>(endpoint: string, options?: RequestOptions<never>) {
|
||||
return this.request<TResponse, never>("DELETE", endpoint, options);
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs?: number,
|
||||
): Promise<Response> {
|
||||
if (!timeoutMs) return fetch(url, init);
|
||||
|
||||
const timeoutController = new AbortController();
|
||||
let timedOut = false;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
timedOut = true;
|
||||
timeoutController.abort();
|
||||
}, timeoutMs);
|
||||
const signal = this.mergeSignals(init.signal, timeoutController.signal);
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (timedOut) throw new TimeoutError(url, timeoutMs);
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
private mergeSignals(
|
||||
requestSignal: AbortSignal | null | undefined,
|
||||
timeoutSignal: AbortSignal,
|
||||
): AbortSignal {
|
||||
if (!requestSignal) return timeoutSignal;
|
||||
if (requestSignal.aborted) return requestSignal;
|
||||
if (timeoutSignal.aborted) return timeoutSignal;
|
||||
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
|
||||
requestSignal.addEventListener("abort", abort, { once: true });
|
||||
timeoutSignal.addEventListener("abort", abort, { once: true });
|
||||
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
private async createHttpError(
|
||||
response: Response,
|
||||
url: string,
|
||||
): Promise<HttpError> {
|
||||
const text = await response.text();
|
||||
let body: unknown;
|
||||
|
||||
if (text.trim()) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
} else {
|
||||
body = undefined;
|
||||
}
|
||||
|
||||
return new HttpError(response.status, response.statusText, body, url);
|
||||
}
|
||||
|
||||
private async parseResponse<TResponse>(
|
||||
response: Response,
|
||||
url: string,
|
||||
): Promise<TResponse> {
|
||||
if (response.status === 204) return undefined as TResponse;
|
||||
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength === "0") return undefined as TResponse;
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return undefined as TResponse;
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return text as TResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as TResponse;
|
||||
} catch (error) {
|
||||
throw new ResponseParseError(url, error);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeError(
|
||||
error: unknown,
|
||||
url: string,
|
||||
timeoutMs?: number,
|
||||
): Error {
|
||||
if (
|
||||
error instanceof HttpError ||
|
||||
error instanceof NetworkError ||
|
||||
error instanceof TimeoutError ||
|
||||
error instanceof ResponseParseError
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
if (timeoutMs) return new TimeoutError(url, timeoutMs);
|
||||
return new NetworkError("Request aborted", url, error);
|
||||
}
|
||||
|
||||
return new NetworkError(
|
||||
error instanceof Error ? error.message : "Network error",
|
||||
url,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
private buildUrl(
|
||||
base: string,
|
||||
params?: Record<string, string | number | boolean | undefined>,
|
||||
): string {
|
||||
if (!params) return base;
|
||||
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined) query.append(key, String(value));
|
||||
}
|
||||
|
||||
const qs = query.toString();
|
||||
return qs ? `${base}?${qs}` : base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
import { EndpointRegistry, HttpClient } from "./HttpClient";
|
||||
import { ACCOUNT_HEADER, getAccountId } from "@/lib/accountStore";
|
||||
|
||||
export class AuthenticationRequiredError extends Error {
|
||||
constructor() {
|
||||
super("Authentication is required before calling the API.");
|
||||
this.name = "AuthenticationRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export const apiEndpointKeys = {
|
||||
clouds: {
|
||||
list: "clouds.list",
|
||||
services: "clouds.services.list",
|
||||
status: "clouds.status.get",
|
||||
serviceStatus: "clouds.services.status.get",
|
||||
schema: "clouds.services.schema.get",
|
||||
resources: {
|
||||
list: "clouds.services.resources.list",
|
||||
get: "clouds.services.resources.get",
|
||||
create: "clouds.services.resources.create",
|
||||
update: "clouds.services.resources.update",
|
||||
delete: "clouds.services.resources.delete",
|
||||
invoke: "clouds.services.resources.invoke",
|
||||
encrypt: "clouds.services.resources.encrypt",
|
||||
decrypt: "clouds.services.resources.decrypt",
|
||||
createApiGatewayRoute: "clouds.services.resources.apigateway.route.create",
|
||||
},
|
||||
storage: {
|
||||
objects: {
|
||||
list: "clouds.services.storage.objects.list",
|
||||
upload: "clouds.services.storage.objects.upload",
|
||||
download: "clouds.services.storage.objects.download",
|
||||
delete: "clouds.services.storage.objects.delete",
|
||||
copy: "clouds.services.storage.objects.copy",
|
||||
},
|
||||
},
|
||||
nosql: {
|
||||
cosmos: {
|
||||
containers: {
|
||||
list: "clouds.services.nosql.cosmos.containers.list",
|
||||
create: "clouds.services.nosql.cosmos.containers.create",
|
||||
delete: "clouds.services.nosql.cosmos.containers.delete",
|
||||
},
|
||||
items: {
|
||||
list: "clouds.services.nosql.cosmos.items.list",
|
||||
upsert: "clouds.services.nosql.cosmos.items.upsert",
|
||||
delete: "clouds.services.nosql.cosmos.items.delete",
|
||||
query: "clouds.services.nosql.cosmos.items.query",
|
||||
},
|
||||
},
|
||||
items: {
|
||||
list: "clouds.services.nosql.items.list",
|
||||
put: "clouds.services.nosql.items.put",
|
||||
},
|
||||
},
|
||||
database: {
|
||||
sql: {
|
||||
databases: "clouds.services.database.sql.databases.list",
|
||||
tables: "clouds.services.database.sql.tables.list",
|
||||
query: "clouds.services.database.sql.query",
|
||||
},
|
||||
snapshots: {
|
||||
list: "clouds.services.database.snapshots.list",
|
||||
create: "clouds.services.database.snapshots.create",
|
||||
},
|
||||
orderableClasses: {
|
||||
list: "clouds.services.database.orderable-classes.list",
|
||||
},
|
||||
},
|
||||
logs: {
|
||||
query: "clouds.services.logs.query",
|
||||
},
|
||||
k8s: {
|
||||
nodegroups: {
|
||||
list: "clouds.services.k8s.nodegroups.list",
|
||||
create: "clouds.services.k8s.nodegroups.create",
|
||||
delete: "clouds.services.k8s.nodegroups.delete",
|
||||
},
|
||||
fargateProfiles: {
|
||||
list: "clouds.services.k8s.fargate-profiles.list",
|
||||
create: "clouds.services.k8s.fargate-profiles.create",
|
||||
delete: "clouds.services.k8s.fargate-profiles.delete",
|
||||
},
|
||||
},
|
||||
email: {
|
||||
inbox: {
|
||||
clear: "clouds.services.email.inbox.clear",
|
||||
},
|
||||
},
|
||||
childCollections: {
|
||||
list: "clouds.services.childCollections.list",
|
||||
items: {
|
||||
list: "clouds.services.childCollections.items.list",
|
||||
},
|
||||
},
|
||||
configuration: {
|
||||
environments: {
|
||||
list: "clouds.services.configuration.environments.list",
|
||||
create: "clouds.services.configuration.environments.create",
|
||||
delete: "clouds.services.configuration.environments.delete",
|
||||
deployments: {
|
||||
start: "clouds.services.configuration.environments.deployments.start",
|
||||
get: "clouds.services.configuration.environments.deployments.get",
|
||||
},
|
||||
},
|
||||
configurationProfiles: {
|
||||
list: "clouds.services.configuration.profiles.list",
|
||||
create: "clouds.services.configuration.profiles.create",
|
||||
delete: "clouds.services.configuration.profiles.delete",
|
||||
hostedVersions: {
|
||||
list: "clouds.services.configuration.profiles.hosted-versions.list",
|
||||
get: "clouds.services.configuration.profiles.hosted-versions.get",
|
||||
create: "clouds.services.configuration.profiles.hosted-versions.create",
|
||||
delete: "clouds.services.configuration.profiles.hosted-versions.delete",
|
||||
},
|
||||
},
|
||||
deploymentStrategies: {
|
||||
list: "clouds.services.configuration.deployment-strategies.list",
|
||||
create: "clouds.services.configuration.deployment-strategies.create",
|
||||
delete: "clouds.services.configuration.deployment-strategies.delete",
|
||||
},
|
||||
},
|
||||
},
|
||||
aws: {
|
||||
eks: {
|
||||
clusters: {
|
||||
list: "aws.eks.clusters.list",
|
||||
describe: "aws.eks.clusters.describe",
|
||||
},
|
||||
nodegroups: {
|
||||
list: "aws.eks.nodegroups.list",
|
||||
describe: "aws.eks.nodegroups.describe",
|
||||
create: "aws.eks.nodegroups.create",
|
||||
delete: "aws.eks.nodegroups.delete",
|
||||
},
|
||||
fargateProfiles: {
|
||||
list: "aws.eks.fargate-profiles.list",
|
||||
describe: "aws.eks.fargate-profiles.describe",
|
||||
create: "aws.eks.fargate-profiles.create",
|
||||
delete: "aws.eks.fargate-profiles.delete",
|
||||
},
|
||||
},
|
||||
secretsmanager: {
|
||||
secrets: {
|
||||
list: "aws.secretsmanager.secrets.list",
|
||||
create: "aws.secretsmanager.secrets.create",
|
||||
describe: "aws.secretsmanager.secrets.describe",
|
||||
delete: "aws.secretsmanager.secrets.delete",
|
||||
value: {
|
||||
get: "aws.secretsmanager.secrets.value.get",
|
||||
put: "aws.secretsmanager.secrets.value.put",
|
||||
},
|
||||
},
|
||||
},
|
||||
ec2: {
|
||||
instances: {
|
||||
list: "aws.ec2.instances.list",
|
||||
describe: "aws.ec2.instances.describe",
|
||||
create: "aws.ec2.instances.create",
|
||||
start: "aws.ec2.instances.start",
|
||||
stop: "aws.ec2.instances.stop",
|
||||
reboot: "aws.ec2.instances.reboot",
|
||||
terminate: "aws.ec2.instances.terminate",
|
||||
tags: "aws.ec2.instances.tags",
|
||||
createImage: "aws.ec2.instances.create-image",
|
||||
console: "aws.ec2.instances.console",
|
||||
},
|
||||
amis: {
|
||||
list: "aws.ec2.amis.list",
|
||||
deregister: "aws.ec2.amis.deregister",
|
||||
},
|
||||
keyPairs: {
|
||||
list: "aws.ec2.key-pairs.list",
|
||||
create: "aws.ec2.key-pairs.create",
|
||||
delete: "aws.ec2.key-pairs.delete",
|
||||
},
|
||||
securityGroups: {
|
||||
list: "aws.ec2.security-groups.list",
|
||||
create: "aws.ec2.security-groups.create",
|
||||
delete: "aws.ec2.security-groups.delete",
|
||||
authorize: "aws.ec2.security-groups.authorize",
|
||||
revoke: "aws.ec2.security-groups.revoke",
|
||||
authorizeEgress: "aws.ec2.security-groups.authorize-egress",
|
||||
revokeEgress: "aws.ec2.security-groups.revoke-egress",
|
||||
},
|
||||
vpcs: {
|
||||
list: "aws.ec2.vpcs.list",
|
||||
create: "aws.ec2.vpcs.create",
|
||||
delete: "aws.ec2.vpcs.delete",
|
||||
getAttributes: "aws.ec2.vpcs.get-attributes",
|
||||
modifyAttribute: "aws.ec2.vpcs.modify-attribute",
|
||||
},
|
||||
subnets: {
|
||||
list: "aws.ec2.subnets.list",
|
||||
create: "aws.ec2.subnets.create",
|
||||
delete: "aws.ec2.subnets.delete",
|
||||
modifyAttribute: "aws.ec2.subnets.modify-attribute",
|
||||
},
|
||||
internetGateways: {
|
||||
list: "aws.ec2.igw.list",
|
||||
create: "aws.ec2.igw.create",
|
||||
attach: "aws.ec2.igw.attach",
|
||||
detach: "aws.ec2.igw.detach",
|
||||
delete: "aws.ec2.igw.delete",
|
||||
},
|
||||
natGateways: {
|
||||
list: "aws.ec2.nat.list",
|
||||
create: "aws.ec2.nat.create",
|
||||
delete: "aws.ec2.nat.delete",
|
||||
},
|
||||
routeTables: {
|
||||
list: "aws.ec2.rtb.list",
|
||||
create: "aws.ec2.rtb.create",
|
||||
delete: "aws.ec2.rtb.delete",
|
||||
createRoute: "aws.ec2.rtb.route.create",
|
||||
deleteRoute: "aws.ec2.rtb.route.delete",
|
||||
associate: "aws.ec2.rtb.associate",
|
||||
disassociate: "aws.ec2.rtb.disassociate",
|
||||
},
|
||||
elasticIps: {
|
||||
list: "aws.ec2.eip.list",
|
||||
create: "aws.ec2.eip.create",
|
||||
release: "aws.ec2.eip.release",
|
||||
associate: "aws.ec2.eip.associate",
|
||||
disassociate: "aws.ec2.eip.disassociate",
|
||||
},
|
||||
availabilityZones: "aws.ec2.availability-zones",
|
||||
instanceTypes: "aws.ec2.instance-types",
|
||||
vpcWizard: "aws.ec2.vpc-wizard",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
|
||||
export const endpointRegistry: EndpointRegistry = new Map([
|
||||
// Cloud Proxy
|
||||
[
|
||||
apiEndpointKeys.clouds.list,
|
||||
{
|
||||
path: "/clouds",
|
||||
method: "GET",
|
||||
telemetry: { provider: "system", service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.services,
|
||||
{
|
||||
path: "/clouds/:cloud/services",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.status,
|
||||
{
|
||||
path: "/clouds/:cloud/status",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.serviceStatus,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/status",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.schema,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/schema",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.invoke,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/invoke",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.createApiGatewayRoute,
|
||||
{
|
||||
path: "/clouds/:cloud/services/apigateway/resources/:id/routes",
|
||||
method: "POST",
|
||||
telemetry: { service: "apigateway" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.get,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.encrypt,
|
||||
{
|
||||
path: "/clouds/:cloud/services/kms/resources/:id/encrypt",
|
||||
method: "POST",
|
||||
telemetry: { service: "kms" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.decrypt,
|
||||
{
|
||||
path: "/clouds/:cloud/services/kms/resources/:id/decrypt",
|
||||
method: "POST",
|
||||
telemetry: { service: "kms" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.create,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.update,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id",
|
||||
method: "PATCH",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
|
||||
[
|
||||
apiEndpointKeys.clouds.resources.invoke,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/invoke",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
|
||||
[
|
||||
apiEndpointKeys.clouds.storage.objects.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/objects",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.storage.objects.upload,
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/object",
|
||||
method: "PUT",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.storage.objects.download,
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/object",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.storage.objects.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/object",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.storage.objects.copy,
|
||||
{
|
||||
path: "/clouds/:cloud/services/storage/resources/:id/object/copy",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.nosql.cosmos.containers.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.nosql.cosmos.containers.create,
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.nosql.cosmos.containers.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.nosql.cosmos.items.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.nosql.cosmos.items.upsert,
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.nosql.cosmos.items.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items/:itemId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.nosql.cosmos.items.query,
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/query",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.database.sql.databases,
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/resources/:id/sql/databases",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.database.sql.tables,
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/resources/:id/sql/tables",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.database.sql.query,
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/resources/:id/sql/query",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.logs.query,
|
||||
{
|
||||
path: "/clouds/:cloud/services/logs/resources/:id/query",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.database.snapshots.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/snapshots",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.database.snapshots.create,
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/snapshots",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.database.orderableClasses.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/database/orderable-classes",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.nosql.items.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/items",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.nosql.items.put,
|
||||
{
|
||||
path: "/clouds/:cloud/services/nosql/resources/:id/items",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.k8s.nodegroups.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/nodegroups",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.k8s.nodegroups.create,
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/nodegroups",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.k8s.nodegroups.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/nodegroups/:nodegroupId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.k8s.fargateProfiles.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/fargate-profiles",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.k8s.fargateProfiles.create,
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/fargate-profiles",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.k8s.fargateProfiles.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/k8s/resources/:id/fargate-profiles/:profileId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.email.inbox.clear,
|
||||
{
|
||||
path: "/clouds/:cloud/services/email/inbox",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.environments.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/environments",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.environments.create,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/environments",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.environments.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/environments/:environmentId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.environments.deployments.start,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/environments/:environmentId/deployments",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.environments.deployments.get,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/environments/:environmentId/deployments/:deploymentNumber",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.create,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.get,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions/:versionNumber",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.create,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/resources/:id/configuration-profiles/:profileId/hosted-configuration-versions/:versionNumber",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.deploymentStrategies.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/deployment-strategies",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.deploymentStrategies.create,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/deployment-strategies",
|
||||
method: "POST",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.configuration.deploymentStrategies.delete,
|
||||
{
|
||||
path: "/clouds/:cloud/services/configuration/deployment-strategies/:strategyId",
|
||||
method: "DELETE",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
|
||||
[
|
||||
apiEndpointKeys.clouds.childCollections.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/collections",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.childCollections.items.list,
|
||||
{
|
||||
path: "/clouds/:cloud/services/:service/resources/:id/collections/:cid/items",
|
||||
method: "GET",
|
||||
telemetry: { service: "cloud-proxy" },
|
||||
},
|
||||
],
|
||||
// AWS EKS
|
||||
[
|
||||
apiEndpointKeys.aws.eks.clusters.list,
|
||||
{
|
||||
path: "/eks/clusters",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.eks.clusters.describe,
|
||||
{
|
||||
path: "/eks/clusters/:name",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.eks.nodegroups.list,
|
||||
{
|
||||
path: "/eks/clusters/:name/nodegroups",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.eks.nodegroups.describe,
|
||||
{
|
||||
path: "/eks/clusters/:name/nodegroups/:nodegroup",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.eks.nodegroups.create,
|
||||
{
|
||||
path: "/eks/clusters/:name/nodegroups",
|
||||
method: "POST",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.eks.nodegroups.delete,
|
||||
{
|
||||
path: "/eks/clusters/:name/nodegroups/:nodegroup",
|
||||
method: "DELETE",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.eks.fargateProfiles.list,
|
||||
{
|
||||
path: "/eks/clusters/:name/fargate-profiles",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.eks.fargateProfiles.describe,
|
||||
{
|
||||
path: "/eks/clusters/:name/fargate-profiles/:profile",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.eks.fargateProfiles.create,
|
||||
{
|
||||
path: "/eks/clusters/:name/fargate-profiles",
|
||||
method: "POST",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.eks.fargateProfiles.delete,
|
||||
{
|
||||
path: "/eks/clusters/:name/fargate-profiles/:profile",
|
||||
method: "DELETE",
|
||||
telemetry: { provider: "aws", service: "eks" },
|
||||
},
|
||||
],
|
||||
|
||||
// AWS Secrets Manager
|
||||
[
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.list,
|
||||
{
|
||||
path: "/secretsmanager/secrets",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.create,
|
||||
{
|
||||
path: "/secretsmanager/secrets",
|
||||
method: "POST",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.describe,
|
||||
{
|
||||
path: "/secretsmanager/secret",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.delete,
|
||||
{
|
||||
path: "/secretsmanager/secret",
|
||||
method: "DELETE",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.value.get,
|
||||
{
|
||||
path: "/secretsmanager/secret/value",
|
||||
method: "GET",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.value.put,
|
||||
{
|
||||
path: "/secretsmanager/secret/value",
|
||||
method: "PUT",
|
||||
telemetry: { provider: "aws", service: "secretsmanager" },
|
||||
},
|
||||
],
|
||||
|
||||
// AWS EC2
|
||||
[apiEndpointKeys.aws.ec2.instances.list, { path: "/ec2/instances", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.describe, { path: "/ec2/instances/:instanceId", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.create, { path: "/ec2/instances", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.start, { path: "/ec2/instances/:instanceId/start", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.stop, { path: "/ec2/instances/:instanceId/stop", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.reboot, { path: "/ec2/instances/:instanceId/reboot", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.terminate,{ path: "/ec2/instances/:instanceId/terminate", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.tags, { path: "/ec2/instances/:instanceId/tags", method: "PUT", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.createImage, { path: "/ec2/instances/:instanceId/image", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instances.console, { path: "/ec2/instances/:instanceId/console", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.amis.list, { path: "/ec2/amis", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.amis.deregister, { path: "/ec2/amis/:imageId/deregister", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.keyPairs.list, { path: "/ec2/key-pairs", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.keyPairs.create, { path: "/ec2/key-pairs", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.keyPairs.delete, { path: "/ec2/key-pairs/:name", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.list, { path: "/ec2/security-groups", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.create, { path: "/ec2/security-groups", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.delete, { path: "/ec2/security-groups/:groupId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.authorize, { path: "/ec2/security-groups/:groupId/ingress", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.revoke, { path: "/ec2/security-groups/:groupId/ingress", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.authorizeEgress, { path: "/ec2/security-groups/:groupId/egress", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.securityGroups.revokeEgress, { path: "/ec2/security-groups/:groupId/egress", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.list, { path: "/ec2/vpcs", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.create, { path: "/ec2/vpcs", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.delete, { path: "/ec2/vpcs/:vpcId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.getAttributes, { path: "/ec2/vpcs/:vpcId/attributes", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcs.modifyAttribute, { path: "/ec2/vpcs/:vpcId/attributes", method: "PUT", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.list, { path: "/ec2/subnets", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.create, { path: "/ec2/subnets", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.delete, { path: "/ec2/subnets/:subnetId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.subnets.modifyAttribute, { path: "/ec2/subnets/:subnetId/attributes", method: "PUT", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.list, { path: "/ec2/internet-gateways", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.create, { path: "/ec2/internet-gateways", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.attach, { path: "/ec2/internet-gateways/:igwId/attach", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.detach, { path: "/ec2/internet-gateways/:igwId/detach", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.internetGateways.delete, { path: "/ec2/internet-gateways/:igwId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.natGateways.list, { path: "/ec2/nat-gateways", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.natGateways.create, { path: "/ec2/nat-gateways", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.natGateways.delete, { path: "/ec2/nat-gateways/:natId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.list, { path: "/ec2/route-tables", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.create, { path: "/ec2/route-tables", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.delete, { path: "/ec2/route-tables/:rtbId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.createRoute, { path: "/ec2/route-tables/:rtbId/routes", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.deleteRoute, { path: "/ec2/route-tables/:rtbId/routes", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.associate, { path: "/ec2/route-tables/:rtbId/associations", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.routeTables.disassociate,{ path: "/ec2/route-table-associations/:associationId", method: "DELETE", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.list, { path: "/ec2/elastic-ips", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.create, { path: "/ec2/elastic-ips", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.release, { path: "/ec2/elastic-ips/:allocationId/release", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.associate, { path: "/ec2/elastic-ips/:allocationId/associate", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.elasticIps.disassociate, { path: "/ec2/elastic-ips/:allocationId/disassociate", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.availabilityZones, { path: "/ec2/availability-zones", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.instanceTypes, { path: "/ec2/instance-types", method: "GET", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
[apiEndpointKeys.aws.ec2.vpcWizard, { path: "/ec2/vpc-wizard", method: "POST", telemetry: { provider: "aws", service: "ec2" } }],
|
||||
|
||||
]);
|
||||
|
||||
// ─── Client Factory ───────────────────────────────────────────────────────────
|
||||
|
||||
export function createApiClient(
|
||||
getToken?: () => string | null | Promise<string | null>,
|
||||
) {
|
||||
const client = new HttpClient(
|
||||
{
|
||||
baseUrl: API_BASE_URL,
|
||||
defaultHeaders: { Accept: "application/json" },
|
||||
timeout: 10_000,
|
||||
},
|
||||
endpointRegistry,
|
||||
);
|
||||
|
||||
// Account scope — every request carries the active AWS account so Floci can
|
||||
// isolate resources per account (the API maps this header to its SDK creds).
|
||||
client.addRequestInterceptor((url, init) => {
|
||||
const headers = (init.headers ?? {}) as Record<string, string>;
|
||||
headers[ACCOUNT_HEADER] = getAccountId();
|
||||
init.headers = headers;
|
||||
return { url, init };
|
||||
});
|
||||
|
||||
// Auth interceptor — attaches Bearer token if available
|
||||
if (getToken) {
|
||||
client.addRequestInterceptor(async (url, init) => {
|
||||
const token = await getToken();
|
||||
if (token) {
|
||||
(init.headers as Record<string, string>)["Authorization"] =
|
||||
`Bearer ${token}`;
|
||||
}
|
||||
return { url, init };
|
||||
});
|
||||
}
|
||||
|
||||
// Logging interceptor (dev only)
|
||||
if (import.meta.env.DEV) {
|
||||
client.addResponseInterceptor((res) => {
|
||||
console.debug(`[HTTP] ${res.status} ${res.url}`);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
// client.addResponseInterceptor((res) => {
|
||||
// if (res.status === 401) {
|
||||
// handleUnauthorizedApiResponse();
|
||||
// }
|
||||
// return res;
|
||||
// });
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export const apiClient = createApiClient();
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEc2SecurityGroup } from "./ec2.api";
|
||||
|
||||
describe("createEc2SecurityGroup", () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ groupId: "sg-created" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it.each(["vpc-selected", undefined])(
|
||||
"sends one JSON object through HttpClient with VPC %s",
|
||||
async (vpcId) => {
|
||||
const name = "web-servers";
|
||||
const description = 'Allow HTTP for "public" clients';
|
||||
|
||||
await expect(createEc2SecurityGroup(name, description, vpcId)).resolves.toEqual({
|
||||
groupId: "sg-created",
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toContain("/api/ec2/security-groups");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
name,
|
||||
description,
|
||||
...(vpcId === undefined ? {} : { vpcId }),
|
||||
});
|
||||
expect(new Headers(init?.headers).get("content-type")).toBe("application/json");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,730 @@
|
||||
import { apiClient, apiEndpointKeys } from "@/api/api";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type Ec2Tag = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Ec2SecurityGroupRef = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type Ec2IpPermission = {
|
||||
protocol: string;
|
||||
fromPort: number | null;
|
||||
toPort: number | null;
|
||||
ipRanges: string[];
|
||||
ipv6Ranges: string[];
|
||||
};
|
||||
|
||||
export type Ec2SecurityGroup = {
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
description: string;
|
||||
vpcId?: string;
|
||||
inboundRules: Ec2IpPermission[];
|
||||
outboundRules: Ec2IpPermission[];
|
||||
tags: Ec2Tag[];
|
||||
};
|
||||
|
||||
export type Ec2Image = {
|
||||
imageId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
state?: string;
|
||||
architecture?: string;
|
||||
platform?: string;
|
||||
virtualizationType?: string;
|
||||
rootDeviceType?: string;
|
||||
createdAt?: string;
|
||||
ownerId?: string;
|
||||
public?: boolean;
|
||||
tags: Ec2Tag[];
|
||||
};
|
||||
|
||||
export type Ec2Instance = {
|
||||
instanceId: string;
|
||||
name: string;
|
||||
state?: string;
|
||||
instanceType?: string;
|
||||
availabilityZone?: string;
|
||||
publicIpAddress?: string;
|
||||
privateIpAddress?: string;
|
||||
vpcId?: string;
|
||||
subnetId?: string;
|
||||
imageId?: string;
|
||||
keyName?: string;
|
||||
launchTime?: string;
|
||||
architecture?: string;
|
||||
platform?: string;
|
||||
securityGroups: Ec2SecurityGroupRef[];
|
||||
tags: Ec2Tag[];
|
||||
};
|
||||
|
||||
export type Ec2KeyPair = {
|
||||
keyPairId: string;
|
||||
keyName: string;
|
||||
keyFingerprint?: string;
|
||||
tags: Ec2Tag[];
|
||||
};
|
||||
|
||||
export type Ec2KeyPairMaterial = {
|
||||
keyPairId: string;
|
||||
keyName: string;
|
||||
keyMaterial: string;
|
||||
};
|
||||
|
||||
export type Ec2Vpc = {
|
||||
vpcId: string;
|
||||
cidrBlock: string;
|
||||
state?: string;
|
||||
isDefault: boolean;
|
||||
tags: Ec2Tag[];
|
||||
};
|
||||
|
||||
export type Ec2Subnet = {
|
||||
subnetId: string;
|
||||
vpcId: string;
|
||||
cidrBlock: string;
|
||||
availabilityZone: string;
|
||||
availableIpAddressCount?: number;
|
||||
mapPublicIpOnLaunch: boolean;
|
||||
state?: string;
|
||||
tags: Ec2Tag[];
|
||||
};
|
||||
|
||||
export type Ec2AvailabilityZone = {
|
||||
zoneName: string;
|
||||
zoneId: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type Ec2InstanceType = {
|
||||
instanceType: string;
|
||||
vcpu: number;
|
||||
memoryMiB: number;
|
||||
};
|
||||
|
||||
export type Ec2VpcAttributes = {
|
||||
enableDnsHostnames: boolean;
|
||||
enableDnsSupport: boolean;
|
||||
};
|
||||
|
||||
export type Ec2ConsoleOutput = {
|
||||
instanceId: string;
|
||||
output: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
export type RunEc2InstanceInput = {
|
||||
name: string;
|
||||
imageId: string;
|
||||
instanceType: string;
|
||||
keyName?: string;
|
||||
subnetId?: string;
|
||||
securityGroupIds?: string[];
|
||||
userData?: string;
|
||||
iamInstanceProfileName?: string;
|
||||
associatePublicIpAddress?: boolean;
|
||||
rootVolumeSize?: number;
|
||||
rootVolumeType?: string;
|
||||
};
|
||||
|
||||
export type CreateAmiInput = {
|
||||
name: string;
|
||||
description?: string;
|
||||
noReboot?: boolean;
|
||||
};
|
||||
|
||||
export type IpPermissionInput = {
|
||||
protocol: string;
|
||||
fromPort: number;
|
||||
toPort: number;
|
||||
cidr: string;
|
||||
};
|
||||
|
||||
export type SubnetGroup = {
|
||||
name: string;
|
||||
count: number;
|
||||
isPublic: boolean;
|
||||
az?: string;
|
||||
prefix?: number;
|
||||
};
|
||||
|
||||
export type VpcWizardInput = {
|
||||
name: string;
|
||||
cidrBlock: string;
|
||||
subnetGroups: SubnetGroup[];
|
||||
natGateway: boolean;
|
||||
availabilityZone?: string;
|
||||
};
|
||||
|
||||
export type VpcWizardResult = {
|
||||
vpcId: string;
|
||||
igwId: string;
|
||||
publicSubnetIds: string[];
|
||||
privateSubnetIds: string[];
|
||||
subnetGroups: Array<{ name: string; isPublic: boolean; subnetIds: string[] }>;
|
||||
natGatewayId?: string;
|
||||
eipAllocationId?: string;
|
||||
publicRouteTableId: string;
|
||||
privateRouteTableId?: string;
|
||||
};
|
||||
|
||||
export type Ec2InternetGateway = {
|
||||
internetGatewayId: string;
|
||||
attachments: Array<{ vpcId: string; state: string }>;
|
||||
tags: Ec2Tag[];
|
||||
};
|
||||
|
||||
export type Ec2NatGateway = {
|
||||
natGatewayId: string;
|
||||
subnetId: string;
|
||||
vpcId: string;
|
||||
state?: string;
|
||||
publicIp?: string;
|
||||
privateIp?: string;
|
||||
eipAllocationId?: string;
|
||||
tags: Ec2Tag[];
|
||||
};
|
||||
|
||||
export type Ec2Route = {
|
||||
destinationCidrBlock?: string;
|
||||
gatewayId?: string;
|
||||
natGatewayId?: string;
|
||||
vpcPeeringConnectionId?: string;
|
||||
state?: string;
|
||||
origin?: string;
|
||||
};
|
||||
|
||||
export type Ec2RouteTableAssociation = {
|
||||
associationId: string;
|
||||
subnetId?: string;
|
||||
isMain: boolean;
|
||||
};
|
||||
|
||||
export type Ec2RouteTable = {
|
||||
routeTableId: string;
|
||||
vpcId: string;
|
||||
routes: Ec2Route[];
|
||||
associations: Ec2RouteTableAssociation[];
|
||||
tags: Ec2Tag[];
|
||||
};
|
||||
|
||||
export type Ec2ElasticIp = {
|
||||
allocationId: string;
|
||||
publicIp: string;
|
||||
domain?: string;
|
||||
associationId?: string;
|
||||
instanceId?: string;
|
||||
networkInterfaceId?: string;
|
||||
tags: Ec2Tag[];
|
||||
};
|
||||
|
||||
export type CreateNatGatewayInput = {
|
||||
name?: string;
|
||||
subnetId: string;
|
||||
allocationId?: string;
|
||||
};
|
||||
|
||||
export type CreateRouteInput = {
|
||||
destinationCidrBlock: string;
|
||||
gatewayId?: string;
|
||||
natGatewayId?: string;
|
||||
vpcPeeringConnectionId?: string;
|
||||
};
|
||||
|
||||
export type ResourceSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// ─── Functions ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listEc2Instances(signal?: AbortSignal): Promise<Ec2Instance[]> {
|
||||
const res = await apiClient.call<Ec2Instance[]>(apiEndpointKeys.aws.ec2.instances.list, { signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function describeEc2Instance(instanceId: string, signal?: AbortSignal): Promise<Ec2Instance> {
|
||||
const res = await apiClient.call<Ec2Instance>(
|
||||
apiEndpointKeys.aws.ec2.instances.describe,
|
||||
{ signal },
|
||||
{ instanceId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function runEc2Instance(input: RunEc2InstanceInput): Promise<Ec2Instance> {
|
||||
const res = await apiClient.call<Ec2Instance>(
|
||||
apiEndpointKeys.aws.ec2.instances.create,
|
||||
{ body: JSON.stringify(input), headers: { "content-type": "application/json" } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function startEc2Instance(instanceId: string): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.instances.start,
|
||||
{ body: "{}", headers: { "content-type": "application/json" } },
|
||||
{ instanceId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopEc2Instance(instanceId: string): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.instances.stop,
|
||||
{ body: "{}", headers: { "content-type": "application/json" } },
|
||||
{ instanceId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function rebootEc2Instance(instanceId: string): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.instances.reboot,
|
||||
{ body: "{}", headers: { "content-type": "application/json" } },
|
||||
{ instanceId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function terminateEc2Instance(instanceId: string): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.instances.terminate,
|
||||
{},
|
||||
{ instanceId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateEc2InstanceTags(
|
||||
instanceId: string,
|
||||
toAdd: Ec2Tag[],
|
||||
toRemove: string[],
|
||||
): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.instances.tags,
|
||||
{ body: JSON.stringify({ toAdd, toRemove }), headers: { "content-type": "application/json" } },
|
||||
{ instanceId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function createEc2Ami(instanceId: string, input: CreateAmiInput): Promise<{ imageId: string }> {
|
||||
const res = await apiClient.call<{ imageId: string }>(
|
||||
apiEndpointKeys.aws.ec2.instances.createImage,
|
||||
{ body: JSON.stringify(input), headers: { "content-type": "application/json" } },
|
||||
{ instanceId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getEc2ConsoleOutput(instanceId: string, signal?: AbortSignal): Promise<Ec2ConsoleOutput> {
|
||||
const res = await apiClient.call<Ec2ConsoleOutput>(
|
||||
apiEndpointKeys.aws.ec2.instances.console,
|
||||
{ signal },
|
||||
{ instanceId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listEc2Amis(signal?: AbortSignal): Promise<Ec2Image[]> {
|
||||
const res = await apiClient.call<Ec2Image[]>(apiEndpointKeys.aws.ec2.amis.list, { signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deregisterEc2Ami(imageId: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.aws.ec2.amis.deregister, {}, { imageId });
|
||||
}
|
||||
|
||||
export async function listEc2KeyPairs(signal?: AbortSignal): Promise<Ec2KeyPair[]> {
|
||||
const res = await apiClient.call<Ec2KeyPair[]>(apiEndpointKeys.aws.ec2.keyPairs.list, { signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createEc2KeyPair(name: string): Promise<Ec2KeyPairMaterial> {
|
||||
const res = await apiClient.call<Ec2KeyPairMaterial>(
|
||||
apiEndpointKeys.aws.ec2.keyPairs.create,
|
||||
{ body: JSON.stringify({ name }), headers: { "content-type": "application/json" } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteEc2KeyPair(name: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.aws.ec2.keyPairs.delete, {}, { name });
|
||||
}
|
||||
|
||||
export async function listEc2SecurityGroups(vpcId?: string, signal?: AbortSignal): Promise<Ec2SecurityGroup[]> {
|
||||
const res = await apiClient.call<Ec2SecurityGroup[]>(
|
||||
apiEndpointKeys.aws.ec2.securityGroups.list,
|
||||
{ params: { vpcId }, signal },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createEc2SecurityGroup(
|
||||
name: string,
|
||||
description: string,
|
||||
vpcId?: string,
|
||||
): Promise<{ groupId: string }> {
|
||||
const res = await apiClient.call<
|
||||
{ groupId: string },
|
||||
{ name: string; description: string; vpcId?: string }
|
||||
>(
|
||||
apiEndpointKeys.aws.ec2.securityGroups.create,
|
||||
{ body: { name, description, vpcId } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteEc2SecurityGroup(groupId: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.aws.ec2.securityGroups.delete, {}, { groupId });
|
||||
}
|
||||
|
||||
export async function authorizeEc2SecurityGroupIngress(
|
||||
groupId: string,
|
||||
permission: IpPermissionInput,
|
||||
): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.securityGroups.authorize,
|
||||
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } },
|
||||
{ groupId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function revokeEc2SecurityGroupIngress(
|
||||
groupId: string,
|
||||
permission: IpPermissionInput,
|
||||
): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.securityGroups.revoke,
|
||||
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } },
|
||||
{ groupId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function authorizeEc2SecurityGroupEgress(
|
||||
groupId: string,
|
||||
permission: IpPermissionInput,
|
||||
): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.securityGroups.authorizeEgress,
|
||||
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } },
|
||||
{ groupId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function revokeEc2SecurityGroupEgress(
|
||||
groupId: string,
|
||||
permission: IpPermissionInput,
|
||||
): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.securityGroups.revokeEgress,
|
||||
{ body: JSON.stringify(permission), headers: { "content-type": "application/json" } },
|
||||
{ groupId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function listEc2AvailabilityZones(signal?: AbortSignal): Promise<Ec2AvailabilityZone[]> {
|
||||
const res = await apiClient.call<Ec2AvailabilityZone[]>(apiEndpointKeys.aws.ec2.availabilityZones, { signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listEc2InstanceTypes(signal?: AbortSignal): Promise<Ec2InstanceType[]> {
|
||||
const res = await apiClient.call<Ec2InstanceType[]>(apiEndpointKeys.aws.ec2.instanceTypes, { signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function modifyEc2SubnetAttribute(subnetId: string, mapPublicIpOnLaunch: boolean): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.subnets.modifyAttribute,
|
||||
{ body: JSON.stringify({ mapPublicIpOnLaunch }), headers: { "content-type": "application/json" } },
|
||||
{ subnetId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function getEc2VpcAttributes(vpcId: string, signal?: AbortSignal): Promise<Ec2VpcAttributes> {
|
||||
const res = await apiClient.call<Ec2VpcAttributes>(
|
||||
apiEndpointKeys.aws.ec2.vpcs.getAttributes,
|
||||
{ signal },
|
||||
{ vpcId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function modifyEc2VpcAttribute(
|
||||
vpcId: string,
|
||||
attribute: "enableDnsHostnames" | "enableDnsSupport",
|
||||
value: boolean,
|
||||
): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.vpcs.modifyAttribute,
|
||||
{ body: JSON.stringify({ attribute, value }), headers: { "content-type": "application/json" } },
|
||||
{ vpcId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function associateEc2ElasticIp(
|
||||
allocationId: string,
|
||||
instanceId: string,
|
||||
): Promise<{ associationId: string }> {
|
||||
const res = await apiClient.call<{ associationId: string }>(
|
||||
apiEndpointKeys.aws.ec2.elasticIps.associate,
|
||||
{ body: JSON.stringify({ instanceId }), headers: { "content-type": "application/json" } },
|
||||
{ allocationId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function disassociateEc2ElasticIp(allocationId: string, associationId: string): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.elasticIps.disassociate,
|
||||
{ body: JSON.stringify({ associationId }), headers: { "content-type": "application/json" } },
|
||||
{ allocationId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function listEc2Vpcs(signal?: AbortSignal): Promise<Ec2Vpc[]> {
|
||||
const res = await apiClient.call<Ec2Vpc[]>(apiEndpointKeys.aws.ec2.vpcs.list, { signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createEc2Vpc(cidrBlock: string): Promise<Ec2Vpc> {
|
||||
const res = await apiClient.call<Ec2Vpc>(
|
||||
apiEndpointKeys.aws.ec2.vpcs.create,
|
||||
{ body: JSON.stringify({ cidrBlock }), headers: { "content-type": "application/json" } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteEc2Vpc(vpcId: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.aws.ec2.vpcs.delete, {}, { vpcId });
|
||||
}
|
||||
|
||||
export async function listEc2Subnets(vpcId?: string, signal?: AbortSignal): Promise<Ec2Subnet[]> {
|
||||
const res = await apiClient.call<Ec2Subnet[]>(
|
||||
apiEndpointKeys.aws.ec2.subnets.list,
|
||||
{ params: { vpcId }, signal },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createEc2Subnet(
|
||||
vpcId: string,
|
||||
cidrBlock: string,
|
||||
availabilityZone?: string,
|
||||
): Promise<Ec2Subnet> {
|
||||
const res = await apiClient.call<Ec2Subnet>(
|
||||
apiEndpointKeys.aws.ec2.subnets.create,
|
||||
{ body: JSON.stringify({ vpcId, cidrBlock, availabilityZone }), headers: { "content-type": "application/json" } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteEc2Subnet(subnetId: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.aws.ec2.subnets.delete, {}, { subnetId });
|
||||
}
|
||||
|
||||
export async function listEc2InternetGateways(signal?: AbortSignal): Promise<Ec2InternetGateway[]> {
|
||||
const res = await apiClient.call<Ec2InternetGateway[]>(apiEndpointKeys.aws.ec2.internetGateways.list, { signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createEc2InternetGateway(name?: string): Promise<Ec2InternetGateway> {
|
||||
const res = await apiClient.call<Ec2InternetGateway>(
|
||||
apiEndpointKeys.aws.ec2.internetGateways.create,
|
||||
{ body: JSON.stringify({ name }), headers: { "content-type": "application/json" } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function attachEc2InternetGateway(igwId: string, vpcId: string): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.internetGateways.attach,
|
||||
{ body: JSON.stringify({ vpcId }), headers: { "content-type": "application/json" } },
|
||||
{ igwId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function detachEc2InternetGateway(igwId: string, vpcId: string): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.internetGateways.detach,
|
||||
{ body: JSON.stringify({ vpcId }), headers: { "content-type": "application/json" } },
|
||||
{ igwId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteEc2InternetGateway(igwId: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.aws.ec2.internetGateways.delete, {}, { igwId });
|
||||
}
|
||||
|
||||
export async function listEc2NatGateways(vpcId?: string, signal?: AbortSignal): Promise<Ec2NatGateway[]> {
|
||||
const res = await apiClient.call<Ec2NatGateway[]>(
|
||||
apiEndpointKeys.aws.ec2.natGateways.list,
|
||||
{ params: { vpcId }, signal },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createEc2NatGateway(input: CreateNatGatewayInput): Promise<Ec2NatGateway> {
|
||||
const res = await apiClient.call<Ec2NatGateway>(
|
||||
apiEndpointKeys.aws.ec2.natGateways.create,
|
||||
{ body: JSON.stringify(input), headers: { "content-type": "application/json" } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteEc2NatGateway(natId: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.aws.ec2.natGateways.delete, {}, { natId });
|
||||
}
|
||||
|
||||
export async function listEc2RouteTables(vpcId?: string, signal?: AbortSignal): Promise<Ec2RouteTable[]> {
|
||||
const res = await apiClient.call<Ec2RouteTable[]>(
|
||||
apiEndpointKeys.aws.ec2.routeTables.list,
|
||||
{ params: { vpcId }, signal },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createEc2RouteTable(vpcId: string, name?: string): Promise<Ec2RouteTable> {
|
||||
const res = await apiClient.call<Ec2RouteTable>(
|
||||
apiEndpointKeys.aws.ec2.routeTables.create,
|
||||
{ body: JSON.stringify({ vpcId, name }), headers: { "content-type": "application/json" } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteEc2RouteTable(rtbId: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.aws.ec2.routeTables.delete, {}, { rtbId });
|
||||
}
|
||||
|
||||
export async function createEc2Route(rtbId: string, input: CreateRouteInput): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.routeTables.createRoute,
|
||||
{ body: JSON.stringify(input), headers: { "content-type": "application/json" } },
|
||||
{ rtbId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteEc2Route(rtbId: string, cidr: string): Promise<void> {
|
||||
await apiClient.call(
|
||||
apiEndpointKeys.aws.ec2.routeTables.deleteRoute,
|
||||
{ params: { cidr } },
|
||||
{ rtbId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function associateEc2RouteTable(rtbId: string, subnetId: string): Promise<string> {
|
||||
const res = await apiClient.call<{ associationId: string }>(
|
||||
apiEndpointKeys.aws.ec2.routeTables.associate,
|
||||
{ body: JSON.stringify({ subnetId }), headers: { "content-type": "application/json" } },
|
||||
{ rtbId },
|
||||
);
|
||||
return res.data.associationId;
|
||||
}
|
||||
|
||||
export async function disassociateEc2RouteTable(associationId: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.aws.ec2.routeTables.disassociate, {}, { associationId });
|
||||
}
|
||||
|
||||
export async function listEc2ElasticIps(signal?: AbortSignal): Promise<Ec2ElasticIp[]> {
|
||||
const res = await apiClient.call<Ec2ElasticIp[]>(apiEndpointKeys.aws.ec2.elasticIps.list, { signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function allocateEc2ElasticIp(name?: string): Promise<Ec2ElasticIp> {
|
||||
const res = await apiClient.call<Ec2ElasticIp>(
|
||||
apiEndpointKeys.aws.ec2.elasticIps.create,
|
||||
{ body: JSON.stringify({ name }), headers: { "content-type": "application/json" } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function releaseEc2ElasticIp(allocationId: string): Promise<void> {
|
||||
await apiClient.call(apiEndpointKeys.aws.ec2.elasticIps.release, {}, { allocationId });
|
||||
}
|
||||
|
||||
export async function createVpcWizard(input: VpcWizardInput): Promise<VpcWizardResult> {
|
||||
const res = await apiClient.call<VpcWizardResult>(
|
||||
apiEndpointKeys.aws.ec2.vpcWizard,
|
||||
{ body: JSON.stringify(input), headers: { "content-type": "application/json" } },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listEc2Resources(signal?: AbortSignal): Promise<ResourceSummary[]> {
|
||||
const instances = await listEc2Instances(signal);
|
||||
return instances.map((instance) => ({
|
||||
id: instance.instanceId,
|
||||
name: instance.name,
|
||||
status: instance.state,
|
||||
metadata: {
|
||||
instanceType: instance.instanceType,
|
||||
az: instance.availabilityZone,
|
||||
publicIp: instance.publicIpAddress,
|
||||
privateIp: instance.privateIpAddress,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Client ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ec2Client = {
|
||||
listInstances: listEc2Instances,
|
||||
describeInstance: describeEc2Instance,
|
||||
runInstance: runEc2Instance,
|
||||
startInstance: startEc2Instance,
|
||||
stopInstance: stopEc2Instance,
|
||||
rebootInstance: rebootEc2Instance,
|
||||
terminateInstance: terminateEc2Instance,
|
||||
updateInstanceTags: updateEc2InstanceTags,
|
||||
createAmi: createEc2Ami,
|
||||
getConsoleOutput: getEc2ConsoleOutput,
|
||||
listAmis: listEc2Amis,
|
||||
deregisterAmi: deregisterEc2Ami,
|
||||
listResources: listEc2Resources,
|
||||
listKeyPairs: listEc2KeyPairs,
|
||||
createKeyPair: createEc2KeyPair,
|
||||
deleteKeyPair: deleteEc2KeyPair,
|
||||
listSecurityGroups: listEc2SecurityGroups,
|
||||
createSecurityGroup: createEc2SecurityGroup,
|
||||
deleteSecurityGroup: deleteEc2SecurityGroup,
|
||||
authorizeSecurityGroupIngress: authorizeEc2SecurityGroupIngress,
|
||||
revokeSecurityGroupIngress: revokeEc2SecurityGroupIngress,
|
||||
authorizeSecurityGroupEgress: authorizeEc2SecurityGroupEgress,
|
||||
revokeSecurityGroupEgress: revokeEc2SecurityGroupEgress,
|
||||
listAvailabilityZones: listEc2AvailabilityZones,
|
||||
listInstanceTypes: listEc2InstanceTypes,
|
||||
modifySubnetAttribute: modifyEc2SubnetAttribute,
|
||||
getVpcAttributes: getEc2VpcAttributes,
|
||||
modifyVpcAttribute: modifyEc2VpcAttribute,
|
||||
associateElasticIp: associateEc2ElasticIp,
|
||||
disassociateElasticIp: disassociateEc2ElasticIp,
|
||||
listVpcs: listEc2Vpcs,
|
||||
createVpc: createEc2Vpc,
|
||||
deleteVpc: deleteEc2Vpc,
|
||||
listSubnets: listEc2Subnets,
|
||||
createSubnet: createEc2Subnet,
|
||||
deleteSubnet: deleteEc2Subnet,
|
||||
listInternetGateways: listEc2InternetGateways,
|
||||
createInternetGateway: createEc2InternetGateway,
|
||||
attachInternetGateway: attachEc2InternetGateway,
|
||||
detachInternetGateway: detachEc2InternetGateway,
|
||||
deleteInternetGateway: deleteEc2InternetGateway,
|
||||
listNatGateways: listEc2NatGateways,
|
||||
createNatGateway: createEc2NatGateway,
|
||||
deleteNatGateway: deleteEc2NatGateway,
|
||||
listRouteTables: listEc2RouteTables,
|
||||
createRouteTable: createEc2RouteTable,
|
||||
deleteRouteTable: deleteEc2RouteTable,
|
||||
createRoute: createEc2Route,
|
||||
deleteRoute: deleteEc2Route,
|
||||
associateRouteTable: associateEc2RouteTable,
|
||||
disassociateRouteTable: disassociateEc2RouteTable,
|
||||
listElasticIps: listEc2ElasticIps,
|
||||
allocateElasticIp: allocateEc2ElasticIp,
|
||||
releaseElasticIp: releaseEc2ElasticIp,
|
||||
createVpcWizard,
|
||||
};
|
||||
@@ -0,0 +1,493 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
allocateEc2ElasticIp,
|
||||
associateEc2ElasticIp,
|
||||
associateEc2RouteTable,
|
||||
attachEc2InternetGateway,
|
||||
authorizeEc2SecurityGroupEgress,
|
||||
authorizeEc2SecurityGroupIngress,
|
||||
createEc2Ami,
|
||||
createEc2InternetGateway,
|
||||
createEc2KeyPair,
|
||||
createEc2NatGateway,
|
||||
createEc2Route,
|
||||
createEc2RouteTable,
|
||||
createEc2SecurityGroup,
|
||||
createEc2Subnet,
|
||||
createEc2Vpc,
|
||||
deregisterEc2Ami,
|
||||
deleteEc2InternetGateway,
|
||||
deleteEc2KeyPair,
|
||||
deleteEc2NatGateway,
|
||||
deleteEc2Route,
|
||||
deleteEc2RouteTable,
|
||||
deleteEc2SecurityGroup,
|
||||
deleteEc2Subnet,
|
||||
deleteEc2Vpc,
|
||||
detachEc2InternetGateway,
|
||||
disassociateEc2ElasticIp,
|
||||
disassociateEc2RouteTable,
|
||||
modifyEc2SubnetAttribute,
|
||||
modifyEc2VpcAttribute,
|
||||
rebootEc2Instance,
|
||||
releaseEc2ElasticIp,
|
||||
revokeEc2SecurityGroupEgress,
|
||||
revokeEc2SecurityGroupIngress,
|
||||
runEc2Instance,
|
||||
startEc2Instance,
|
||||
stopEc2Instance,
|
||||
terminateEc2Instance,
|
||||
updateEc2InstanceTags,
|
||||
} from "./ec2.api";
|
||||
import type {
|
||||
CreateAmiInput,
|
||||
CreateNatGatewayInput,
|
||||
CreateRouteInput,
|
||||
Ec2Tag,
|
||||
IpPermissionInput,
|
||||
RunEc2InstanceInput,
|
||||
} from "./ec2.api";
|
||||
import { ec2QueryKeys } from "./ec2.queries";
|
||||
|
||||
// ─── Instance lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateEc2InstanceMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: RunEc2InstanceInput) => runEc2Instance(input),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartEc2InstanceMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (instanceId: string) => startEc2Instance(instanceId),
|
||||
onSuccess: (_data, instanceId) => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instance(instanceId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStopEc2InstanceMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (instanceId: string) => stopEc2Instance(instanceId),
|
||||
onSuccess: (_data, instanceId) => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instance(instanceId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRebootEc2InstanceMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (instanceId: string) => rebootEc2Instance(instanceId),
|
||||
onSuccess: (_data, instanceId) => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instance(instanceId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTerminateEc2InstanceMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (instanceId: string) => terminateEc2Instance(instanceId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateEc2InstanceTagsMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ instanceId, toAdd, toRemove }: { instanceId: string; toAdd: Ec2Tag[]; toRemove: string[] }) =>
|
||||
updateEc2InstanceTags(instanceId, toAdd, toRemove),
|
||||
onSuccess: (_data, { instanceId }) => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instance(instanceId) });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── AMIs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateAmiMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ instanceId, input }: { instanceId: string; input: CreateAmiInput }) =>
|
||||
createEc2Ami(instanceId, input),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.amis });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeregisterAmiMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (imageId: string) => deregisterEc2Ami(imageId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.amis });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Key pairs ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateKeyPairMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => createEc2KeyPair(name),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.keyPairs });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteKeyPairMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => deleteEc2KeyPair(name),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.keyPairs });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Security groups ──────────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateSecurityGroupMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ name, description, vpcId }: { name: string; description: string; vpcId: string }) =>
|
||||
createEc2SecurityGroup(name, description, vpcId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSecurityGroupMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (groupId: string) => deleteEc2SecurityGroup(groupId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuthorizeSecurityGroupIngressMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ groupId, permission }: { groupId: string; permission: IpPermissionInput }) =>
|
||||
authorizeEc2SecurityGroupIngress(groupId, permission),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeSecurityGroupIngressMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ groupId, permission }: { groupId: string; permission: IpPermissionInput }) =>
|
||||
revokeEc2SecurityGroupIngress(groupId, permission),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuthorizeSecurityGroupEgressMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ groupId, permission }: { groupId: string; permission: IpPermissionInput }) =>
|
||||
authorizeEc2SecurityGroupEgress(groupId, permission),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeSecurityGroupEgressMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ groupId, permission }: { groupId: string; permission: IpPermissionInput }) =>
|
||||
revokeEc2SecurityGroupEgress(groupId, permission),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── VPCs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateVpcMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (cidrBlock: string) => createEc2Vpc(cidrBlock),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.vpcs });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteVpcMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (vpcId: string) => deleteEc2Vpc(vpcId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.vpcs });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.securityGroupsAll });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Subnets ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateSubnetMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
vpcId,
|
||||
cidrBlock,
|
||||
availabilityZone,
|
||||
}: {
|
||||
vpcId: string;
|
||||
cidrBlock: string;
|
||||
availabilityZone?: string;
|
||||
}) => createEc2Subnet(vpcId, cidrBlock, availabilityZone),
|
||||
onSuccess: (_data, { vpcId }) => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnets(vpcId) });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnets() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSubnetMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (subnetId: string) => deleteEc2Subnet(subnetId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Internet Gateways ────────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateInternetGatewayMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name?: string) => createEc2InternetGateway(name),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAttachInternetGatewayMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ igwId, vpcId }: { igwId: string; vpcId: string }) =>
|
||||
attachEc2InternetGateway(igwId, vpcId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDetachInternetGatewayMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ igwId, vpcId }: { igwId: string; vpcId: string }) =>
|
||||
detachEc2InternetGateway(igwId, vpcId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteInternetGatewayMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (igwId: string) => deleteEc2InternetGateway(igwId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── NAT Gateways ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateNatGatewayMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateNatGatewayInput) => createEc2NatGateway(input),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.natGatewaysAll });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteNatGatewayMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (natId: string) => deleteEc2NatGateway(natId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.natGatewaysAll });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Route Tables ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useCreateRouteTableMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ vpcId, name }: { vpcId: string; name?: string }) =>
|
||||
createEc2RouteTable(vpcId, name),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRouteTableMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (rtbId: string) => deleteEc2RouteTable(rtbId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRouteMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ rtbId, input }: { rtbId: string; input: CreateRouteInput }) =>
|
||||
createEc2Route(rtbId, input),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRouteMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ rtbId, cidr }: { rtbId: string; cidr: string }) =>
|
||||
deleteEc2Route(rtbId, cidr),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssociateRouteTableMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ rtbId, subnetId }: { rtbId: string; subnetId: string }) =>
|
||||
associateEc2RouteTable(rtbId, subnetId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisassociateRouteTableMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (associationId: string) => disassociateEc2RouteTable(associationId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTablesAll });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Elastic IPs ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function useAllocateElasticIpMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name?: string) => allocateEc2ElasticIp(name),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReleaseElasticIpMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (allocationId: string) => releaseEc2ElasticIp(allocationId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssociateElasticIpMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ allocationId, instanceId }: { allocationId: string; instanceId: string }) =>
|
||||
associateEc2ElasticIp(allocationId, instanceId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisassociateElasticIpMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ allocationId, associationId }: { allocationId: string; associationId: string }) =>
|
||||
disassociateEc2ElasticIp(allocationId, associationId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps });
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.instances });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useModifySubnetAttributeMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ subnetId, mapPublicIpOnLaunch }: { subnetId: string; mapPublicIpOnLaunch: boolean }) =>
|
||||
modifyEc2SubnetAttribute(subnetId, mapPublicIpOnLaunch),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.subnetsAll });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useModifyVpcAttributeMutation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ vpcId, attribute, value }: { vpcId: string; attribute: "enableDnsHostnames" | "enableDnsSupport"; value: boolean }) =>
|
||||
modifyEc2VpcAttribute(vpcId, attribute, value),
|
||||
onSuccess: (_data, { vpcId }) => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.vpcAttributes(vpcId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ec2Client } from "./ec2.api";
|
||||
|
||||
export const ec2QueryKeys = {
|
||||
instances: ["ec2", "instances"] as const,
|
||||
instance: (instanceId: string | null) => ["ec2", "instance", instanceId] as const,
|
||||
amis: ["ec2", "amis"] as const,
|
||||
keyPairs: ["ec2", "key-pairs"] as const,
|
||||
// Parameterized list queries — call with vpcId for scoped, omit for all
|
||||
securityGroups: (vpcId?: string) => ["ec2", "security-groups", vpcId ?? "all"] as const,
|
||||
subnets: (vpcId?: string) => ["ec2", "subnets", vpcId ?? "all"] as const,
|
||||
natGateways: (vpcId?: string) => ["ec2", "nat-gateways", vpcId ?? "all"] as const,
|
||||
routeTables: (vpcId?: string) => ["ec2", "route-tables", vpcId ?? "all"] as const,
|
||||
// Root keys — use in mutations to invalidate all variants of a parameterized query
|
||||
securityGroupsAll: ["ec2", "security-groups"] as const,
|
||||
subnetsAll: ["ec2", "subnets"] as const,
|
||||
natGatewaysAll: ["ec2", "nat-gateways"] as const,
|
||||
routeTablesAll: ["ec2", "route-tables"] as const,
|
||||
// Static list keys
|
||||
vpcs: ["ec2", "vpcs"] as const,
|
||||
consoleOutput: (instanceId: string | null) => ["ec2", "console", instanceId] as const,
|
||||
internetGateways: ["ec2", "internet-gateways"] as const,
|
||||
elasticIps: ["ec2", "elastic-ips"] as const,
|
||||
availabilityZones: ["ec2", "availability-zones"] as const,
|
||||
instanceTypes: ["ec2", "instance-types"] as const,
|
||||
vpcAttributes: (vpcId: string) => ["ec2", "vpc-attributes", vpcId] as const,
|
||||
};
|
||||
|
||||
export function useEc2InstancesQuery() {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.instances,
|
||||
queryFn: ({ signal }) => ec2Client.listInstances(signal),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2InstanceQuery(instanceId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.instance(instanceId),
|
||||
queryFn: ({ signal }) => ec2Client.describeInstance(instanceId!, signal),
|
||||
enabled: Boolean(instanceId),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2AmisQuery() {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.amis,
|
||||
queryFn: ({ signal }) => ec2Client.listAmis(signal),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2KeyPairsQuery(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.keyPairs,
|
||||
queryFn: ({ signal }) => ec2Client.listKeyPairs(signal),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2SecurityGroupsQuery(vpcId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.securityGroups(vpcId),
|
||||
queryFn: ({ signal }) => ec2Client.listSecurityGroups(vpcId, signal),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2VpcsQuery(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.vpcs,
|
||||
queryFn: ({ signal }) => ec2Client.listVpcs(signal),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2SubnetsQuery(vpcId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.subnets(vpcId),
|
||||
queryFn: ({ signal }) => ec2Client.listSubnets(vpcId, signal),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2ConsoleOutputQuery(instanceId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.consoleOutput(instanceId),
|
||||
queryFn: ({ signal }) => ec2Client.getConsoleOutput(instanceId!, signal),
|
||||
enabled: Boolean(instanceId),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2InternetGatewaysQuery(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.internetGateways,
|
||||
queryFn: ({ signal }) => ec2Client.listInternetGateways(signal),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2NatGatewaysQuery(vpcId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.natGateways(vpcId),
|
||||
queryFn: ({ signal }) => ec2Client.listNatGateways(vpcId, signal),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2RouteTablesQuery(vpcId?: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.routeTables(vpcId),
|
||||
queryFn: ({ signal }) => ec2Client.listRouteTables(vpcId, signal),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2ElasticIpsQuery(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.elasticIps,
|
||||
queryFn: ({ signal }) => ec2Client.listElasticIps(signal),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2AvailabilityZonesQuery() {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.availabilityZones,
|
||||
queryFn: ({ signal }) => ec2Client.listAvailabilityZones(signal),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2InstanceTypesQuery() {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.instanceTypes,
|
||||
queryFn: ({ signal }) => ec2Client.listInstanceTypes(signal),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEc2VpcAttributesQuery(vpcId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ec2QueryKeys.vpcAttributes(vpcId ?? ""),
|
||||
queryFn: ({ signal }) => ec2Client.getVpcAttributes(vpcId!, signal),
|
||||
enabled: Boolean(vpcId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { apiClient, apiEndpointKeys } from "@/api/api";
|
||||
import type { ResourceSummary } from "@/api/types";
|
||||
|
||||
export type EksVpcConfig = {
|
||||
subnetIds: string[];
|
||||
securityGroupIds: string[];
|
||||
clusterSecurityGroupId?: string;
|
||||
vpcId?: string;
|
||||
endpointPublicAccess?: boolean;
|
||||
endpointPrivateAccess?: boolean;
|
||||
publicAccessCidrs: string[];
|
||||
};
|
||||
|
||||
export type EksCluster = {
|
||||
name: string;
|
||||
arn?: string;
|
||||
createdAt?: string;
|
||||
version?: string;
|
||||
endpoint?: string;
|
||||
roleArn?: string;
|
||||
status?: string;
|
||||
platformVersion?: string;
|
||||
certificateAuthority?: {
|
||||
data?: string;
|
||||
};
|
||||
resourcesVpcConfig?: EksVpcConfig;
|
||||
tags: Record<string, string>;
|
||||
nodegroupCount?: number;
|
||||
fargateProfileCount?: number;
|
||||
};
|
||||
|
||||
export type EksNodegroup = {
|
||||
name: string;
|
||||
arn?: string;
|
||||
clusterName: string;
|
||||
version?: string;
|
||||
releaseVersion?: string;
|
||||
createdAt?: string;
|
||||
modifiedAt?: string;
|
||||
status?: string;
|
||||
capacityType?: string;
|
||||
instanceTypes: string[];
|
||||
subnets: string[];
|
||||
nodeRole?: string;
|
||||
scalingConfig?: {
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
desiredSize?: number;
|
||||
};
|
||||
labels: Record<string, string>;
|
||||
tags: Record<string, string>;
|
||||
};
|
||||
|
||||
export type EksFargateProfileSelector = {
|
||||
namespace?: string;
|
||||
labels: Record<string, string>;
|
||||
};
|
||||
|
||||
export type EksFargateProfile = {
|
||||
name: string;
|
||||
arn?: string;
|
||||
clusterName: string;
|
||||
createdAt?: string;
|
||||
status?: string;
|
||||
podExecutionRoleArn?: string;
|
||||
subnets: string[];
|
||||
selectors: EksFargateProfileSelector[];
|
||||
tags: Record<string, string>;
|
||||
};
|
||||
|
||||
export type CreateEksNodegroupInput = {
|
||||
name: string;
|
||||
nodeRole: string;
|
||||
subnets: string[];
|
||||
instanceTypes?: string[];
|
||||
scalingConfig?: {
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
desiredSize?: number;
|
||||
};
|
||||
labels?: Record<string, string>;
|
||||
tags?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type CreateEksFargateProfileInput = {
|
||||
name: string;
|
||||
podExecutionRoleArn: string;
|
||||
subnets?: string[];
|
||||
selectors: {
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
}[];
|
||||
tags?: Record<string, string>;
|
||||
};
|
||||
|
||||
export async function listEksClusters(
|
||||
signal?: AbortSignal,
|
||||
): Promise<EksCluster[]> {
|
||||
const res = await apiClient.call<EksCluster[]>(
|
||||
apiEndpointKeys.aws.eks.clusters.list,
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function describeEksCluster(
|
||||
name: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EksCluster> {
|
||||
const res = await apiClient.call<EksCluster>(
|
||||
apiEndpointKeys.aws.eks.clusters.describe,
|
||||
{ signal },
|
||||
{ name },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listEksNodegroups(
|
||||
clusterName: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EksNodegroup[]> {
|
||||
const res = await apiClient.call<EksNodegroup[]>(
|
||||
apiEndpointKeys.aws.eks.nodegroups.list,
|
||||
{ signal },
|
||||
{ name: clusterName },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function describeEksNodegroup(
|
||||
clusterName: string,
|
||||
nodegroupName: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EksNodegroup> {
|
||||
const res = await apiClient.call<EksNodegroup>(
|
||||
apiEndpointKeys.aws.eks.nodegroups.describe,
|
||||
{ signal },
|
||||
{ name: clusterName, nodegroup: nodegroupName },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createEksNodegroup(
|
||||
clusterName: string,
|
||||
input: CreateEksNodegroupInput,
|
||||
): Promise<EksNodegroup> {
|
||||
const res = await apiClient.call<EksNodegroup, CreateEksNodegroupInput>(
|
||||
apiEndpointKeys.aws.eks.nodegroups.create,
|
||||
{ body: input },
|
||||
{ name: clusterName },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteEksNodegroup(
|
||||
clusterName: string,
|
||||
nodegroupName: string,
|
||||
): Promise<EksNodegroup> {
|
||||
const res = await apiClient.call<EksNodegroup>(
|
||||
apiEndpointKeys.aws.eks.nodegroups.delete,
|
||||
{},
|
||||
{ name: clusterName, nodegroup: nodegroupName },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listEksFargateProfiles(
|
||||
clusterName: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EksFargateProfile[]> {
|
||||
const res = await apiClient.call<EksFargateProfile[]>(
|
||||
apiEndpointKeys.aws.eks.fargateProfiles.list,
|
||||
{ signal },
|
||||
{ name: clusterName },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function describeEksFargateProfile(
|
||||
clusterName: string,
|
||||
profileName: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<EksFargateProfile> {
|
||||
const res = await apiClient.call<EksFargateProfile>(
|
||||
apiEndpointKeys.aws.eks.fargateProfiles.describe,
|
||||
{ signal },
|
||||
{ name: clusterName, profile: profileName },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createEksFargateProfile(
|
||||
clusterName: string,
|
||||
input: CreateEksFargateProfileInput,
|
||||
): Promise<EksFargateProfile> {
|
||||
const res = await apiClient.call<
|
||||
EksFargateProfile,
|
||||
CreateEksFargateProfileInput
|
||||
>(
|
||||
apiEndpointKeys.aws.eks.fargateProfiles.create,
|
||||
{ body: input },
|
||||
{ name: clusterName },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteEksFargateProfile(
|
||||
clusterName: string,
|
||||
profileName: string,
|
||||
): Promise<EksFargateProfile> {
|
||||
const res = await apiClient.call<EksFargateProfile>(
|
||||
apiEndpointKeys.aws.eks.fargateProfiles.delete,
|
||||
{},
|
||||
{ name: clusterName, profile: profileName },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listEksResources(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ResourceSummary[]> {
|
||||
const clusters = await listEksClusters(signal);
|
||||
|
||||
return clusters.map((cluster) => ({
|
||||
id: cluster.arn ?? cluster.name,
|
||||
name: cluster.name,
|
||||
status: cluster.status,
|
||||
metadata: {
|
||||
version: cluster.version,
|
||||
platformVersion: cluster.platformVersion,
|
||||
nodegroups: cluster.nodegroupCount ?? 0,
|
||||
fargateProfiles: cluster.fargateProfileCount ?? 0,
|
||||
vpcId: cluster.resourcesVpcConfig?.vpcId,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export const eksClient = {
|
||||
listClusters: listEksClusters,
|
||||
describeCluster: describeEksCluster,
|
||||
listNodegroups: listEksNodegroups,
|
||||
describeNodegroup: describeEksNodegroup,
|
||||
createNodegroup: createEksNodegroup,
|
||||
deleteNodegroup: deleteEksNodegroup,
|
||||
listFargateProfiles: listEksFargateProfiles,
|
||||
describeFargateProfile: describeEksFargateProfile,
|
||||
createFargateProfile: createEksFargateProfile,
|
||||
deleteFargateProfile: deleteEksFargateProfile,
|
||||
listResources: listEksResources,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
eksClient,
|
||||
type CreateEksFargateProfileInput,
|
||||
type CreateEksNodegroupInput,
|
||||
} from "./eks.api";
|
||||
import { eksQueryKeys } from "./eks.queries";
|
||||
|
||||
export type CreateEksNodegroupMutationInput = {
|
||||
clusterName: string;
|
||||
nodegroup: CreateEksNodegroupInput;
|
||||
};
|
||||
|
||||
export type DeleteEksNodegroupMutationInput = {
|
||||
clusterName: string;
|
||||
nodegroupName: string;
|
||||
};
|
||||
|
||||
export type CreateEksFargateProfileMutationInput = {
|
||||
clusterName: string;
|
||||
profile: CreateEksFargateProfileInput;
|
||||
};
|
||||
|
||||
export type DeleteEksFargateProfileMutationInput = {
|
||||
clusterName: string;
|
||||
profileName: string;
|
||||
};
|
||||
|
||||
export function useCreateEksNodegroupMutation() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ clusterName, nodegroup }: CreateEksNodegroupMutationInput) =>
|
||||
eksClient.createNodegroup(clusterName, nodegroup),
|
||||
onSuccess: (_, input) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: eksQueryKeys.nodegroups(input.clusterName),
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: eksQueryKeys.clusters });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteEksNodegroupMutation() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ clusterName, nodegroupName }: DeleteEksNodegroupMutationInput) =>
|
||||
eksClient.deleteNodegroup(clusterName, nodegroupName),
|
||||
onSuccess: (_, input) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: eksQueryKeys.nodegroups(input.clusterName),
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: eksQueryKeys.clusters });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateEksFargateProfileMutation() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ clusterName, profile }: CreateEksFargateProfileMutationInput) =>
|
||||
eksClient.createFargateProfile(clusterName, profile),
|
||||
onSuccess: (_, input) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: eksQueryKeys.fargateProfiles(input.clusterName),
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: eksQueryKeys.clusters });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteEksFargateProfileMutation() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ clusterName, profileName }: DeleteEksFargateProfileMutationInput) =>
|
||||
eksClient.deleteFargateProfile(clusterName, profileName),
|
||||
onSuccess: (_, input) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: eksQueryKeys.fargateProfiles(input.clusterName),
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: eksQueryKeys.clusters });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { eksClient } from "./eks.api";
|
||||
|
||||
export const eksQueryKeys = {
|
||||
clusters: ["eks", "clusters"] as const,
|
||||
cluster: (name: string | null) => ["eks", "cluster", name] as const,
|
||||
nodegroups: (clusterName: string | null) =>
|
||||
["eks", "nodegroups", clusterName] as const,
|
||||
nodegroup: (clusterName: string | null, nodegroupName: string | null) =>
|
||||
["eks", "nodegroup", clusterName, nodegroupName] as const,
|
||||
fargateProfiles: (clusterName: string | null) =>
|
||||
["eks", "fargate-profiles", clusterName] as const,
|
||||
fargateProfile: (clusterName: string | null, profileName: string | null) =>
|
||||
["eks", "fargate-profile", clusterName, profileName] as const,
|
||||
};
|
||||
|
||||
export function useEksClustersQuery() {
|
||||
return useQuery({
|
||||
queryKey: eksQueryKeys.clusters,
|
||||
queryFn: ({ signal }) => eksClient.listClusters(signal),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEksClusterQuery(name: string | null) {
|
||||
return useQuery({
|
||||
queryKey: eksQueryKeys.cluster(name),
|
||||
queryFn: ({ signal }) => eksClient.describeCluster(name!, signal),
|
||||
enabled: Boolean(name),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEksNodegroupsQuery(clusterName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: eksQueryKeys.nodegroups(clusterName),
|
||||
queryFn: ({ signal }) => eksClient.listNodegroups(clusterName!, signal),
|
||||
enabled: Boolean(clusterName),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEksNodegroupQuery(
|
||||
clusterName: string | null,
|
||||
nodegroupName: string | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: eksQueryKeys.nodegroup(clusterName, nodegroupName),
|
||||
queryFn: ({ signal }) =>
|
||||
eksClient.describeNodegroup(clusterName!, nodegroupName!, signal),
|
||||
enabled: Boolean(clusterName && nodegroupName),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEksFargateProfilesQuery(clusterName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: eksQueryKeys.fargateProfiles(clusterName),
|
||||
queryFn: ({ signal }) => eksClient.listFargateProfiles(clusterName!, signal),
|
||||
enabled: Boolean(clusterName),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEksFargateProfileQuery(
|
||||
clusterName: string | null,
|
||||
profileName: string | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: eksQueryKeys.fargateProfile(clusterName, profileName),
|
||||
queryFn: ({ signal }) =>
|
||||
eksClient.describeFargateProfile(clusterName!, profileName!, signal),
|
||||
enabled: Boolean(clusterName && profileName),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { apiClient, apiEndpointKeys } from "@/api/api";
|
||||
import type { ResourceSummary } from "@/api/types";
|
||||
|
||||
export interface SecretTag {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SecretSummary {
|
||||
name: string;
|
||||
arn?: string;
|
||||
description?: string;
|
||||
rotationEnabled: boolean;
|
||||
kmsKeyId?: string;
|
||||
lastChangedDate?: string;
|
||||
lastAccessedDate?: string;
|
||||
createdDate?: string;
|
||||
tags: SecretTag[];
|
||||
}
|
||||
|
||||
export interface SecretDetail extends SecretSummary {
|
||||
deletedDate?: string;
|
||||
versionIds: string[];
|
||||
}
|
||||
|
||||
export interface SecretValue {
|
||||
name: string;
|
||||
arn?: string;
|
||||
versionId?: string;
|
||||
secretString?: string;
|
||||
secretBinary?: string;
|
||||
createdDate?: string;
|
||||
}
|
||||
|
||||
export async function listSecrets(
|
||||
signal?: AbortSignal,
|
||||
): Promise<SecretSummary[]> {
|
||||
const res = await apiClient.call<SecretSummary[]>(
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.list,
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function describeSecret(
|
||||
id: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SecretDetail> {
|
||||
const res = await apiClient.call<SecretDetail>(
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.describe,
|
||||
{ signal, params: { id } },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getSecretValue(
|
||||
id: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SecretValue> {
|
||||
const res = await apiClient.call<SecretValue>(
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.value.get,
|
||||
{ signal, params: { id } },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createSecret(
|
||||
name: string,
|
||||
secretString: string,
|
||||
description?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ name: string; arn?: string; versionId?: string }> {
|
||||
const res = await apiClient.call<
|
||||
{ name: string; arn?: string; versionId?: string },
|
||||
{ name: string; secretString: string; description?: string }
|
||||
>(
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.create,
|
||||
{ signal, body: { name, secretString, description } },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function putSecretValue(
|
||||
id: string,
|
||||
secretString: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ arn?: string; versionId?: string }> {
|
||||
const res = await apiClient.call<
|
||||
{ arn?: string; versionId?: string },
|
||||
{ id: string; secretString: string }
|
||||
>(
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.value.put,
|
||||
{ signal, body: { id, secretString } },
|
||||
);
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteSecret(
|
||||
id: string,
|
||||
force = false,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.aws.secretsmanager.secrets.delete,
|
||||
{ signal, params: { id, force: force ? "true" : undefined } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function listSecretResources(
|
||||
signal?: AbortSignal,
|
||||
): Promise<ResourceSummary[]> {
|
||||
const secrets = await listSecrets(signal);
|
||||
|
||||
return secrets.map((s) => ({
|
||||
id: s.arn ?? s.name,
|
||||
name: s.name,
|
||||
status: s.rotationEnabled ? "rotation enabled" : "available",
|
||||
description: s.description,
|
||||
metadata: {
|
||||
lastChangedDate: s.lastChangedDate,
|
||||
lastAccessedDate: s.lastAccessedDate,
|
||||
createdDate: s.createdDate,
|
||||
tagCount: s.tags.length,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export const secretsManagerClient = {
|
||||
listSecrets,
|
||||
describeSecret,
|
||||
getSecretValue,
|
||||
createSecret,
|
||||
putSecretValue,
|
||||
deleteSecret,
|
||||
listResources: listSecretResources,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type UseMutationOptions,
|
||||
} from "@tanstack/react-query";
|
||||
import { secretsManagerClient } from "./secretsmanager.api";
|
||||
import { secretsManagerQueryKeys } from "./secretsmanager.queries";
|
||||
|
||||
export type CreateSecretMutationInput = {
|
||||
name: string;
|
||||
secretString: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type PutSecretValueMutationInput = {
|
||||
id: string;
|
||||
secretString: string;
|
||||
};
|
||||
|
||||
export type DeleteSecretMutationInput = {
|
||||
id: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export function useCreateSecretMutation(
|
||||
options?: UseMutationOptions<
|
||||
{ name: string; arn?: string; versionId?: string },
|
||||
Error,
|
||||
CreateSecretMutationInput
|
||||
>,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, secretString, description }) =>
|
||||
secretsManagerClient.createSecret(name, secretString, description),
|
||||
...options,
|
||||
onSuccess: (...args) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: secretsManagerQueryKeys.list,
|
||||
});
|
||||
options?.onSuccess?.(...args);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePutSecretValueMutation(
|
||||
options?: UseMutationOptions<
|
||||
{ arn?: string; versionId?: string },
|
||||
Error,
|
||||
PutSecretValueMutationInput
|
||||
>,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, secretString }) =>
|
||||
secretsManagerClient.putSecretValue(id, secretString),
|
||||
...options,
|
||||
onSuccess: (...args) => {
|
||||
const [, { id }] = args;
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: secretsManagerQueryKeys.value(id),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: secretsManagerQueryKeys.detail(id),
|
||||
});
|
||||
options?.onSuccess?.(...args);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSecretMutation(
|
||||
options?: UseMutationOptions<void, Error, DeleteSecretMutationInput>,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, force }) =>
|
||||
secretsManagerClient.deleteSecret(id, force ?? false),
|
||||
...options,
|
||||
onSuccess: (...args) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: secretsManagerQueryKeys.list,
|
||||
});
|
||||
options?.onSuccess?.(...args);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { secretsManagerClient } from "./secretsmanager.api";
|
||||
|
||||
export const secretsManagerQueryKeys = {
|
||||
list: ["secretsmanager", "secrets"] as const,
|
||||
detail: (id: string | null) => ["secretsmanager", "detail", id] as const,
|
||||
value: (id: string | null) => ["secretsmanager", "value", id] as const,
|
||||
};
|
||||
|
||||
export function useSecretsQuery(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: secretsManagerQueryKeys.list,
|
||||
queryFn: ({ signal }) => secretsManagerClient.listSecrets(signal),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSecretDetailQuery(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: secretsManagerQueryKeys.detail(id),
|
||||
queryFn: ({ signal }) => secretsManagerClient.describeSecret(id!, signal),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSecretValueQuery(id: string | null, enabled = false) {
|
||||
return useQuery({
|
||||
queryKey: secretsManagerQueryKeys.value(id),
|
||||
queryFn: ({ signal }) => secretsManagerClient.getSecretValue(id!, signal),
|
||||
enabled: Boolean(id) && enabled,
|
||||
// Keep the plaintext out of the long-lived cache: always refetch on reveal
|
||||
// and drop it the moment the query loses its observer so a hidden value
|
||||
// cannot be read back later.
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
import { API_BASE_URL, apiClient, apiEndpointKeys } from "./api";
|
||||
import type {
|
||||
CloudDescriptor,
|
||||
CloudProvider,
|
||||
CloudServiceDescriptor,
|
||||
CloudServiceStatus,
|
||||
CloudServiceType,
|
||||
CloudStatus,
|
||||
} from "@/types/cloud";
|
||||
import type {
|
||||
AppConfigConfigurationProfile,
|
||||
AppConfigDeployment,
|
||||
AppConfigDeploymentStrategy,
|
||||
AppConfigEnvironment,
|
||||
AppConfigHostedConfigurationVersion,
|
||||
ChildCollection,
|
||||
ChildItem,
|
||||
CloudResource,
|
||||
CollectionPage,
|
||||
CosmosContainer,
|
||||
CosmosItem,
|
||||
CosmosQueryResult,
|
||||
CreateDatabaseSnapshotInput,
|
||||
CreateKubernetesFargateProfileInput,
|
||||
CreateKubernetesNodegroupInput,
|
||||
DatabaseSnapshot,
|
||||
KubernetesFargateProfile,
|
||||
KubernetesNodegroup,
|
||||
LogsInsightsQueryInput,
|
||||
LogsInsightsQueryResult,
|
||||
NoSqlItem,
|
||||
SqlCredentials,
|
||||
SqlDatabase,
|
||||
SqlEngine,
|
||||
SqlQueryResult,
|
||||
SqlTable,
|
||||
StorageObjectList,
|
||||
} from "@/types/resource";
|
||||
import type { ServiceSchema } from "@/types/schema";
|
||||
import { getAccountId } from "@/lib/accountStore";
|
||||
|
||||
type CloudPathParams = Record<string, string>;
|
||||
|
||||
const DATABASE_MUTATION_TIMEOUT_MS = 5 * 60_000;
|
||||
const SQL_DATA_TIMEOUT_MS = 45_000;
|
||||
/** Floci completes Insights queries instantly by default, but bounded server-side polling can take longer. */
|
||||
const LOGS_QUERY_TIMEOUT_MS = 20_000;
|
||||
/** Cold starts pull a container image; measured at ~60s on a first invoke. */
|
||||
const INVOKE_TIMEOUT_MS = 120_000;
|
||||
|
||||
export async function listClouds(
|
||||
signal?: AbortSignal,
|
||||
): Promise<CloudDescriptor[]> {
|
||||
const res = await apiClient.call<CloudDescriptor[]>(
|
||||
apiEndpointKeys.clouds.list,
|
||||
{ signal },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listCloudServices(
|
||||
cloud: CloudProvider,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CloudServiceDescriptor[]> {
|
||||
const res = await apiClient.call<CloudServiceDescriptor[]>(
|
||||
apiEndpointKeys.clouds.services,
|
||||
requestOptions(cloud, "cloud-proxy", { signal }),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCloudStatus(
|
||||
cloud: CloudProvider,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CloudStatus> {
|
||||
const res = await apiClient.call<CloudStatus>(
|
||||
apiEndpointKeys.clouds.status,
|
||||
requestOptions(cloud, "cloud-proxy", { signal }),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCloudServiceStatus(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CloudServiceStatus> {
|
||||
const res = await apiClient.call<CloudServiceStatus>(
|
||||
apiEndpointKeys.clouds.serviceStatus,
|
||||
requestOptions(cloud, service, { signal }),
|
||||
{ cloud, service },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getServiceSchema(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ServiceSchema> {
|
||||
const res = await apiClient.call<ServiceSchema>(
|
||||
apiEndpointKeys.clouds.schema,
|
||||
requestOptions(cloud, service, { signal }),
|
||||
{ cloud, service },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listCloudResources(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
search?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CloudResource[]> {
|
||||
const res = await apiClient.call<CloudResource[]>(
|
||||
apiEndpointKeys.clouds.resources.list,
|
||||
requestOptions(cloud, service, { signal, params: { search } }),
|
||||
{ cloud, service },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCloudResource(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
id: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CloudResource> {
|
||||
const res = await apiClient.call<CloudResource>(
|
||||
apiEndpointKeys.clouds.resources.get,
|
||||
requestOptions(cloud, service, { signal }),
|
||||
{ cloud, service, id },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createCloudResource(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
values: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CloudResource> {
|
||||
const timeout =
|
||||
(cloud === "azure" || cloud === "aws") && service === "database"
|
||||
? DATABASE_MUTATION_TIMEOUT_MS
|
||||
: undefined;
|
||||
const res = await apiClient.call<CloudResource, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.resources.create,
|
||||
requestOptions(cloud, service, { signal, body: values, timeout }),
|
||||
{ cloud, service },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateCloudResource(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
id: string,
|
||||
values: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CloudResource> {
|
||||
const timeout =
|
||||
(cloud === "azure" || cloud === "aws") && service === "database"
|
||||
? DATABASE_MUTATION_TIMEOUT_MS
|
||||
: undefined;
|
||||
const res = await apiClient.call<CloudResource, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.resources.update,
|
||||
requestOptions(cloud, service, { signal, body: values, timeout }),
|
||||
{ cloud, service, id },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteCloudResource(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
id: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.resources.delete,
|
||||
requestOptions(cloud, service, { signal }),
|
||||
{ cloud, service, id },
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearEmailInbox(
|
||||
cloud: CloudProvider,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.email.inbox.clear,
|
||||
requestOptions(cloud, "email", { signal }),
|
||||
{ cloud },
|
||||
);
|
||||
}
|
||||
export interface ServerlessInvokeResult {
|
||||
statusCode: number;
|
||||
payload: string;
|
||||
functionError?: string;
|
||||
logResult?: string;
|
||||
executionDuration?: number;
|
||||
}
|
||||
|
||||
export interface CreateApiGatewayLambdaRouteInput {
|
||||
path: string;
|
||||
method: string;
|
||||
stage: string;
|
||||
functionName: string;
|
||||
}
|
||||
|
||||
export interface ApiGatewayLambdaRoute {
|
||||
resourceId: string;
|
||||
deploymentId: string;
|
||||
invokeUrl: string;
|
||||
}
|
||||
|
||||
export async function createApiGatewayLambdaRoute(
|
||||
cloud: CloudProvider,
|
||||
apiId: string,
|
||||
input: CreateApiGatewayLambdaRouteInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApiGatewayLambdaRoute> {
|
||||
const res = await apiClient.call<ApiGatewayLambdaRoute, CreateApiGatewayLambdaRouteInput>(
|
||||
apiEndpointKeys.clouds.resources.createApiGatewayRoute,
|
||||
requestOptions(cloud, "apigateway", { signal, body: input }),
|
||||
{ cloud, id: apiId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export type KmsEncryptionAlgorithm =
|
||||
| "SYMMETRIC_DEFAULT"
|
||||
| "RSAES_OAEP_SHA_1"
|
||||
| "RSAES_OAEP_SHA_256";
|
||||
|
||||
export interface KmsEncryptRequest {
|
||||
plaintextBase64: string;
|
||||
encryptionAlgorithm: KmsEncryptionAlgorithm;
|
||||
encryptionContext?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface KmsEncryptResponse {
|
||||
ciphertextBlobBase64: string;
|
||||
keyId: string;
|
||||
encryptionAlgorithm: KmsEncryptionAlgorithm;
|
||||
}
|
||||
|
||||
export interface KmsDecryptRequest {
|
||||
ciphertextBlobBase64: string;
|
||||
encryptionAlgorithm: KmsEncryptionAlgorithm;
|
||||
encryptionContext?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface KmsDecryptResponse {
|
||||
plaintextBase64: string;
|
||||
keyId: string;
|
||||
encryptionAlgorithm: KmsEncryptionAlgorithm;
|
||||
}
|
||||
|
||||
export async function invokeCloudResource(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
id: string,
|
||||
payload: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ServerlessInvokeResult> {
|
||||
const res = await apiClient.call<ServerlessInvokeResult, { payload: string }>(
|
||||
apiEndpointKeys.clouds.resources.invoke,
|
||||
requestOptions(cloud, service, {
|
||||
signal,
|
||||
body: { payload },
|
||||
// A cold invoke can exceed a minute while the runtime pulls and starts the
|
||||
// function container, so the 10s default would abort a request that is
|
||||
// working. The user-visible alternative is a timeout error for a function
|
||||
// that in fact ran.
|
||||
timeout: INVOKE_TIMEOUT_MS,
|
||||
}),
|
||||
{ cloud, service, id },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function encryptKmsResource(
|
||||
cloud: CloudProvider,
|
||||
id: string,
|
||||
body: KmsEncryptRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<KmsEncryptResponse> {
|
||||
const res = await apiClient.call<KmsEncryptResponse, KmsEncryptRequest>(
|
||||
apiEndpointKeys.clouds.resources.encrypt,
|
||||
requestOptions(cloud, "kms", { signal, body }),
|
||||
{ cloud, id },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function decryptKmsResource(
|
||||
cloud: CloudProvider,
|
||||
id: string,
|
||||
body: KmsDecryptRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<KmsDecryptResponse> {
|
||||
const res = await apiClient.call<KmsDecryptResponse, KmsDecryptRequest>(
|
||||
apiEndpointKeys.clouds.resources.decrypt,
|
||||
requestOptions(cloud, "kms", { signal, body }),
|
||||
{ cloud, id },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listStorageObjects(
|
||||
cloud: CloudProvider,
|
||||
resourceId: string,
|
||||
prefix?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StorageObjectList> {
|
||||
const res = await apiClient.call<StorageObjectList>(
|
||||
apiEndpointKeys.clouds.storage.objects.list,
|
||||
requestOptions(cloud, "storage", { signal, params: { prefix } }),
|
||||
storagePathParams(cloud, resourceId),
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function uploadStorageObject(
|
||||
cloud: CloudProvider,
|
||||
resourceId: string,
|
||||
key: string,
|
||||
file: File | Blob,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.storage.objects.upload,
|
||||
requestOptions(cloud, "storage", {
|
||||
signal,
|
||||
rawBody: file,
|
||||
params: { key },
|
||||
headers: { "Content-Type": file.type || "application/octet-stream" },
|
||||
}),
|
||||
storagePathParams(cloud, resourceId),
|
||||
);
|
||||
}
|
||||
|
||||
export function storageObjectDownloadUrl(
|
||||
cloud: CloudProvider,
|
||||
resourceId: string,
|
||||
key: string,
|
||||
): string {
|
||||
const path = `/clouds/${encodeURIComponent(
|
||||
cloud,
|
||||
)}/services/storage/resources/${encodeURIComponent(resourceId)}/object`;
|
||||
// This URL is opened directly by the browser (anchor/img), so it bypasses the
|
||||
// request interceptor — pass the account as a query param the API also reads.
|
||||
return `${API_BASE_URL}${path}?key=${encodeURIComponent(
|
||||
key,
|
||||
)}&account=${encodeURIComponent(getAccountId())}`;
|
||||
}
|
||||
|
||||
export async function deleteStorageObject(
|
||||
cloud: CloudProvider,
|
||||
resourceId: string,
|
||||
key: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.storage.objects.delete,
|
||||
requestOptions(cloud, "storage", { signal, params: { key } }),
|
||||
storagePathParams(cloud, resourceId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function copyStorageObject(
|
||||
cloud: CloudProvider,
|
||||
srcResourceId: string,
|
||||
srcKey: string,
|
||||
destKey: string,
|
||||
destResourceId?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.storage.objects.copy,
|
||||
requestOptions(cloud, "storage", {
|
||||
signal,
|
||||
body: { srcKey, destKey, ...(destResourceId ? { destResourceId } : {}) },
|
||||
}),
|
||||
storagePathParams(cloud, srcResourceId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCosmosContainers(
|
||||
cloud: CloudProvider,
|
||||
databaseId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CosmosContainer[]> {
|
||||
const res = await apiClient.call<CosmosContainer[]>(
|
||||
apiEndpointKeys.clouds.nosql.cosmos.containers.list,
|
||||
requestOptions(cloud, "nosql", { signal }),
|
||||
databasePathParams(cloud, databaseId),
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createCosmosContainer(
|
||||
cloud: CloudProvider,
|
||||
databaseId: string,
|
||||
values: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CosmosContainer> {
|
||||
const res = await apiClient.call<CosmosContainer, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.nosql.cosmos.containers.create,
|
||||
requestOptions(cloud, "nosql", { signal, body: values }),
|
||||
databasePathParams(cloud, databaseId),
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteCosmosContainer(
|
||||
cloud: CloudProvider,
|
||||
databaseId: string,
|
||||
containerId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.nosql.cosmos.containers.delete,
|
||||
requestOptions(cloud, "nosql", { signal }),
|
||||
{ ...databasePathParams(cloud, databaseId), containerId },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function listCosmosItems(
|
||||
cloud: CloudProvider,
|
||||
databaseId: string,
|
||||
containerId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CosmosItem[]> {
|
||||
const res = await apiClient.call<CosmosItem[]>(
|
||||
apiEndpointKeys.clouds.nosql.cosmos.items.list,
|
||||
requestOptions(cloud, "nosql", { signal }),
|
||||
{ ...databasePathParams(cloud, databaseId), containerId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function upsertCosmosItem(
|
||||
cloud: CloudProvider,
|
||||
databaseId: string,
|
||||
containerId: string,
|
||||
document: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CosmosItem> {
|
||||
const res = await apiClient.call<CosmosItem, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.nosql.cosmos.items.upsert,
|
||||
requestOptions(cloud, "nosql", { signal, body: document }),
|
||||
{ ...databasePathParams(cloud, databaseId), containerId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteCosmosItem(
|
||||
cloud: CloudProvider,
|
||||
databaseId: string,
|
||||
containerId: string,
|
||||
itemId: string,
|
||||
partitionKey?: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.nosql.cosmos.items.delete,
|
||||
requestOptions(cloud, "nosql", {
|
||||
signal,
|
||||
params: partitionKey ? { partitionKey } : undefined,
|
||||
}),
|
||||
{ ...databasePathParams(cloud, databaseId), containerId, itemId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function queryCosmosItems(
|
||||
cloud: CloudProvider,
|
||||
databaseId: string,
|
||||
containerId: string,
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CosmosQueryResult> {
|
||||
const res = await apiClient.call<CosmosQueryResult, { query: string }>(
|
||||
apiEndpointKeys.clouds.nosql.cosmos.items.query,
|
||||
requestOptions(cloud, "nosql", { signal, body: { query } }),
|
||||
{ ...databasePathParams(cloud, databaseId), containerId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listChildCollections(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
resourceId: string,
|
||||
cursor?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CollectionPage<ChildCollection>> {
|
||||
const res = await apiClient.call<CollectionPage<ChildCollection>>(
|
||||
apiEndpointKeys.clouds.childCollections.list,
|
||||
requestOptions(cloud, service, { signal, params: cursor ? { cursor } : undefined }),
|
||||
{ cloud, service, id: resourceId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listCollectionItems(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
resourceId: string,
|
||||
collectionId: string,
|
||||
cursor?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CollectionPage<ChildItem>> {
|
||||
const res = await apiClient.call<CollectionPage<ChildItem>>(
|
||||
apiEndpointKeys.clouds.childCollections.items.list,
|
||||
requestOptions(cloud, service, { signal, params: cursor ? { cursor } : undefined }),
|
||||
{ cloud, service, id: resourceId, cid: collectionId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listSqlDatabases(
|
||||
cloud: CloudProvider,
|
||||
serverId: string,
|
||||
engine: SqlEngine,
|
||||
credentials: SqlCredentials,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SqlDatabase[]> {
|
||||
const res = await apiClient.call<SqlDatabase[], SqlCredentials & { engine: SqlEngine }>(
|
||||
apiEndpointKeys.clouds.database.sql.databases,
|
||||
requestOptions(cloud, "database", {
|
||||
signal,
|
||||
body: { ...credentials, engine },
|
||||
timeout: SQL_DATA_TIMEOUT_MS,
|
||||
}),
|
||||
{ cloud, id: serverId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listSqlTables(
|
||||
cloud: CloudProvider,
|
||||
serverId: string,
|
||||
engine: SqlEngine,
|
||||
database: string,
|
||||
credentials: SqlCredentials,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SqlTable[]> {
|
||||
const res = await apiClient.call<
|
||||
SqlTable[],
|
||||
SqlCredentials & { database: string; engine: SqlEngine }
|
||||
>(
|
||||
apiEndpointKeys.clouds.database.sql.tables,
|
||||
requestOptions(cloud, "database", {
|
||||
signal,
|
||||
body: { ...credentials, database, engine },
|
||||
timeout: SQL_DATA_TIMEOUT_MS,
|
||||
}),
|
||||
{ cloud, id: serverId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function querySql(
|
||||
cloud: CloudProvider,
|
||||
serverId: string,
|
||||
engine: SqlEngine,
|
||||
database: string,
|
||||
credentials: SqlCredentials,
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SqlQueryResult> {
|
||||
const res = await apiClient.call<
|
||||
SqlQueryResult,
|
||||
SqlCredentials & { database: string; engine: SqlEngine; query: string }
|
||||
>(
|
||||
apiEndpointKeys.clouds.database.sql.query,
|
||||
requestOptions(cloud, "database", {
|
||||
signal,
|
||||
body: { ...credentials, database, engine, query },
|
||||
timeout: SQL_DATA_TIMEOUT_MS,
|
||||
}),
|
||||
{ cloud, id: serverId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function queryLogs(
|
||||
cloud: CloudProvider,
|
||||
logGroupName: string,
|
||||
input: LogsInsightsQueryInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LogsInsightsQueryResult> {
|
||||
const res = await apiClient.call<LogsInsightsQueryResult, LogsInsightsQueryInput>(
|
||||
apiEndpointKeys.clouds.logs.query,
|
||||
requestOptions(cloud, "logs", { signal, body: input, timeout: LOGS_QUERY_TIMEOUT_MS }),
|
||||
{ cloud, id: logGroupName },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listDatabaseSnapshots(
|
||||
cloud: CloudProvider,
|
||||
instanceIdentifier?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DatabaseSnapshot[]> {
|
||||
const res = await apiClient.call<DatabaseSnapshot[]>(
|
||||
apiEndpointKeys.clouds.database.snapshots.list,
|
||||
requestOptions(cloud, "database", {
|
||||
signal,
|
||||
params: instanceIdentifier ? { instanceIdentifier } : undefined,
|
||||
}),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createDatabaseSnapshot(
|
||||
cloud: CloudProvider,
|
||||
input: CreateDatabaseSnapshotInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DatabaseSnapshot> {
|
||||
const res = await apiClient.call<DatabaseSnapshot, CreateDatabaseSnapshotInput>(
|
||||
apiEndpointKeys.clouds.database.snapshots.create,
|
||||
requestOptions(cloud, "database", { signal, body: input }),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listDatabaseOrderableClasses(
|
||||
cloud: CloudProvider,
|
||||
engine?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[]> {
|
||||
const res = await apiClient.call<string[]>(
|
||||
apiEndpointKeys.clouds.database.orderableClasses.list,
|
||||
requestOptions(cloud, "database", {
|
||||
signal,
|
||||
params: engine ? { engine } : undefined,
|
||||
}),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listNoSqlItems(
|
||||
cloud: CloudProvider,
|
||||
resourceId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NoSqlItem[]> {
|
||||
const res = await apiClient.call<NoSqlItem[]>(
|
||||
apiEndpointKeys.clouds.nosql.items.list,
|
||||
requestOptions(cloud, "nosql", { signal }),
|
||||
{ cloud, id: resourceId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function putNoSqlItem(
|
||||
cloud: CloudProvider,
|
||||
resourceId: string,
|
||||
document: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NoSqlItem> {
|
||||
const res = await apiClient.call<NoSqlItem, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.nosql.items.put,
|
||||
requestOptions(cloud, "nosql", { signal, body: document }),
|
||||
{ cloud, id: resourceId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listKubernetesNodegroups(
|
||||
cloud: CloudProvider,
|
||||
clusterId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<KubernetesNodegroup[]> {
|
||||
const res = await apiClient.call<KubernetesNodegroup[]>(
|
||||
apiEndpointKeys.clouds.k8s.nodegroups.list,
|
||||
requestOptions(cloud, "k8s", {signal}),
|
||||
{cloud, id: clusterId},
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createKubernetesNodegroup(
|
||||
cloud: CloudProvider,
|
||||
clusterId: string,
|
||||
input: CreateKubernetesNodegroupInput,
|
||||
): Promise<KubernetesNodegroup> {
|
||||
const res = await apiClient.call<KubernetesNodegroup, CreateKubernetesNodegroupInput>(
|
||||
apiEndpointKeys.clouds.k8s.nodegroups.create,
|
||||
requestOptions(cloud, "k8s", {body: input}),
|
||||
{cloud, id: clusterId},
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteKubernetesNodegroup(
|
||||
cloud: CloudProvider,
|
||||
clusterId: string,
|
||||
nodegroupId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.k8s.nodegroups.delete,
|
||||
requestOptions(cloud, "k8s"),
|
||||
{cloud, id: clusterId, nodegroupId},
|
||||
)
|
||||
}
|
||||
|
||||
export async function listKubernetesFargateProfiles(
|
||||
cloud: CloudProvider,
|
||||
clusterId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<KubernetesFargateProfile[]> {
|
||||
const res = await apiClient.call<KubernetesFargateProfile[]>(
|
||||
apiEndpointKeys.clouds.k8s.fargateProfiles.list,
|
||||
requestOptions(cloud, "k8s", {signal}),
|
||||
{cloud, id: clusterId},
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createKubernetesFargateProfile(
|
||||
cloud: CloudProvider,
|
||||
clusterId: string,
|
||||
input: CreateKubernetesFargateProfileInput,
|
||||
): Promise<KubernetesFargateProfile> {
|
||||
const res = await apiClient.call<KubernetesFargateProfile, CreateKubernetesFargateProfileInput>(
|
||||
apiEndpointKeys.clouds.k8s.fargateProfiles.create,
|
||||
requestOptions(cloud, "k8s", {body: input}),
|
||||
{cloud, id: clusterId},
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function deleteKubernetesFargateProfile(
|
||||
cloud: CloudProvider,
|
||||
clusterId: string,
|
||||
profileId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.k8s.fargateProfiles.delete,
|
||||
requestOptions(cloud, "k8s"),
|
||||
{cloud, id: clusterId, profileId},
|
||||
)
|
||||
}
|
||||
|
||||
export async function listAppConfigEnvironments(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AppConfigEnvironment[]> {
|
||||
const res = await apiClient.call<AppConfigEnvironment[]>(
|
||||
apiEndpointKeys.clouds.configuration.environments.list,
|
||||
requestOptions(cloud, "configuration", {signal}),
|
||||
{cloud, id: applicationId},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createAppConfigEnvironment(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<AppConfigEnvironment> {
|
||||
const res = await apiClient.call<AppConfigEnvironment, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.configuration.environments.create,
|
||||
requestOptions(cloud, "configuration", {body: values}),
|
||||
{cloud, id: applicationId},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteAppConfigEnvironment(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
environmentId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.configuration.environments.delete,
|
||||
requestOptions(cloud, "configuration"),
|
||||
{cloud, id: applicationId, environmentId},
|
||||
);
|
||||
}
|
||||
|
||||
export async function listAppConfigConfigurationProfiles(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AppConfigConfigurationProfile[]> {
|
||||
const res = await apiClient.call<AppConfigConfigurationProfile[]>(
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.list,
|
||||
requestOptions(cloud, "configuration", {signal}),
|
||||
{cloud, id: applicationId},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createAppConfigConfigurationProfile(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<AppConfigConfigurationProfile> {
|
||||
const res = await apiClient.call<AppConfigConfigurationProfile, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.create,
|
||||
requestOptions(cloud, "configuration", {body: values}),
|
||||
{cloud, id: applicationId},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteAppConfigConfigurationProfile(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
profileId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.delete,
|
||||
requestOptions(cloud, "configuration"),
|
||||
{cloud, id: applicationId, profileId},
|
||||
);
|
||||
}
|
||||
|
||||
export async function listAppConfigHostedConfigurationVersions(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
profileId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AppConfigHostedConfigurationVersion[]> {
|
||||
const res = await apiClient.call<AppConfigHostedConfigurationVersion[]>(
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.list,
|
||||
requestOptions(cloud, "configuration", {signal}),
|
||||
{cloud, id: applicationId, profileId},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getAppConfigHostedConfigurationVersion(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
profileId: string,
|
||||
versionNumber: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AppConfigHostedConfigurationVersion> {
|
||||
const res = await apiClient.call<AppConfigHostedConfigurationVersion>(
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.get,
|
||||
requestOptions(cloud, "configuration", {signal}),
|
||||
{cloud, id: applicationId, profileId, versionNumber: String(versionNumber)},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createAppConfigHostedConfigurationVersion(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
profileId: string,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<AppConfigHostedConfigurationVersion> {
|
||||
const res = await apiClient.call<AppConfigHostedConfigurationVersion, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.create,
|
||||
requestOptions(cloud, "configuration", {body: values}),
|
||||
{cloud, id: applicationId, profileId},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteAppConfigHostedConfigurationVersion(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
profileId: string,
|
||||
versionNumber: number,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.configuration.configurationProfiles.hostedVersions.delete,
|
||||
requestOptions(cloud, "configuration"),
|
||||
{cloud, id: applicationId, profileId, versionNumber: String(versionNumber)},
|
||||
);
|
||||
}
|
||||
|
||||
export async function listAppConfigDeploymentStrategies(
|
||||
cloud: CloudProvider,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AppConfigDeploymentStrategy[]> {
|
||||
const res = await apiClient.call<AppConfigDeploymentStrategy[]>(
|
||||
apiEndpointKeys.clouds.configuration.deploymentStrategies.list,
|
||||
requestOptions(cloud, "configuration", {signal}),
|
||||
{cloud},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createAppConfigDeploymentStrategy(
|
||||
cloud: CloudProvider,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<AppConfigDeploymentStrategy> {
|
||||
const res = await apiClient.call<AppConfigDeploymentStrategy, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.configuration.deploymentStrategies.create,
|
||||
requestOptions(cloud, "configuration", {body: values}),
|
||||
{cloud},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteAppConfigDeploymentStrategy(
|
||||
cloud: CloudProvider,
|
||||
strategyId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void>(
|
||||
apiEndpointKeys.clouds.configuration.deploymentStrategies.delete,
|
||||
requestOptions(cloud, "configuration"),
|
||||
{cloud, strategyId},
|
||||
);
|
||||
}
|
||||
|
||||
export async function startAppConfigDeployment(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
environmentId: string,
|
||||
values: Record<string, unknown>,
|
||||
): Promise<AppConfigDeployment> {
|
||||
const res = await apiClient.call<AppConfigDeployment, Record<string, unknown>>(
|
||||
apiEndpointKeys.clouds.configuration.environments.deployments.start,
|
||||
requestOptions(cloud, "configuration", {body: values}),
|
||||
{cloud, id: applicationId, environmentId},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getAppConfigDeployment(
|
||||
cloud: CloudProvider,
|
||||
applicationId: string,
|
||||
environmentId: string,
|
||||
deploymentNumber: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AppConfigDeployment> {
|
||||
const res = await apiClient.call<AppConfigDeployment>(
|
||||
apiEndpointKeys.clouds.configuration.environments.deployments.get,
|
||||
requestOptions(cloud, "configuration", {signal}),
|
||||
{cloud, id: applicationId, environmentId, deploymentNumber: String(deploymentNumber)},
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function requestOptions<TBody = unknown>(
|
||||
cloud: CloudProvider,
|
||||
service: string,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
params?: Record<string, string | number | boolean | undefined>;
|
||||
body?: TBody;
|
||||
rawBody?: BodyInit;
|
||||
headers?: HeadersInit;
|
||||
/** Overrides the client default; needed for calls that can legitimately run long. */
|
||||
timeout?: number;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
...options,
|
||||
telemetry: { provider: cloud, service },
|
||||
};
|
||||
}
|
||||
|
||||
function storagePathParams(
|
||||
cloud: CloudProvider,
|
||||
resourceId: string,
|
||||
): CloudPathParams {
|
||||
return { cloud, id: resourceId };
|
||||
}
|
||||
|
||||
function databasePathParams(
|
||||
cloud: CloudProvider,
|
||||
databaseId: string,
|
||||
): CloudPathParams {
|
||||
return { cloud, id: databaseId };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { apiClient } from "./api";
|
||||
import type { HttpMethod } from "./HttpClient";
|
||||
import {
|
||||
subscribeApiRequests,
|
||||
type ApiRequestEvent,
|
||||
} from "@/telemetry";
|
||||
|
||||
export type FlociRequestEvent = ApiRequestEvent;
|
||||
|
||||
export function subscribeRequests(
|
||||
cb: (event: FlociRequestEvent) => void,
|
||||
): () => void {
|
||||
return subscribeApiRequests(cb);
|
||||
}
|
||||
|
||||
export function apiGet<T>(
|
||||
path: string,
|
||||
service: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
return apiRequest<T>("GET", path, service, undefined, signal);
|
||||
}
|
||||
|
||||
export function apiPost<T>(
|
||||
path: string,
|
||||
service: string,
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
return apiRequest<T>("POST", path, service, body, signal);
|
||||
}
|
||||
|
||||
export function apiPut<T>(
|
||||
path: string,
|
||||
service: string,
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
return apiRequest<T>("PUT", path, service, body, signal);
|
||||
}
|
||||
|
||||
export function apiDelete<T>(
|
||||
path: string,
|
||||
service: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
return apiRequest<T>("DELETE", path, service, undefined, signal);
|
||||
}
|
||||
|
||||
async function apiRequest<T>(
|
||||
method: HttpMethod,
|
||||
path: string,
|
||||
service: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const res = await apiClient.request<T>(method, path, {
|
||||
signal,
|
||||
...(body === undefined ? {} : { body }),
|
||||
telemetry: { provider: "aws", service },
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {
|
||||
getCloudServiceStatus,
|
||||
getCloudStatus,
|
||||
listCloudResources,
|
||||
listCloudServices,
|
||||
listClouds,
|
||||
} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider, CloudServiceDescriptor, CloudServiceType, CloudStatus} from '@/types/cloud'
|
||||
|
||||
/**
|
||||
* Shared cloud queries.
|
||||
*
|
||||
* These live under api/ rather than in a feature folder because the app shell
|
||||
* (nav, connection indicator) needs them as much as the console home does.
|
||||
* Key strings are unchanged so existing caches still share.
|
||||
*/
|
||||
export const cloudQueryKeys = {
|
||||
clouds: ['clouds'] as const,
|
||||
services: (cloud: CloudProvider) => ['cloud-services', cloud] as const,
|
||||
status: (cloud: CloudProvider) => ['cloud-status', cloud] as const,
|
||||
serviceStatus: (cloud: CloudProvider, service: CloudServiceType) =>
|
||||
['cloud-service-status', cloud, service] as const,
|
||||
resources: (cloud: CloudProvider, service: CloudServiceType) =>
|
||||
['cloud-console-resources', cloud, service] as const,
|
||||
}
|
||||
|
||||
export function useCloudsQuery() {
|
||||
return useQuery({
|
||||
queryKey: cloudQueryKeys.clouds,
|
||||
queryFn: ({signal}) => listClouds(signal),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCloudServicesQuery(cloud: CloudProvider) {
|
||||
return useQuery({
|
||||
queryKey: cloudQueryKeys.services(cloud),
|
||||
queryFn: ({signal}) => listCloudServices(cloud, signal),
|
||||
// The catalog only changes when the API restarts, so this is effectively
|
||||
// free on every navigation while still driving the whole nav.
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCloudStatusQuery(cloud: CloudProvider, options: {refetchInterval?: number} = {}) {
|
||||
return useQuery({
|
||||
queryKey: cloudQueryKeys.status(cloud),
|
||||
queryFn: ({signal}) => getCloudStatus(cloud, signal),
|
||||
refetchInterval: options.refetchInterval ?? 10_000,
|
||||
})
|
||||
}
|
||||
|
||||
/** Health of the single service being viewed, rather than of the whole cloud. */
|
||||
export function useCloudServiceStatusQuery(
|
||||
cloud: CloudProvider,
|
||||
service: CloudServiceType,
|
||||
options: {enabled?: boolean} = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: cloudQueryKeys.serviceStatus(cloud, service),
|
||||
queryFn: ({signal}) => getCloudServiceStatus(cloud, service, signal),
|
||||
refetchInterval: 10_000,
|
||||
enabled: options.enabled ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCloudConsoleResourcesQuery({
|
||||
cloud,
|
||||
service,
|
||||
services,
|
||||
status,
|
||||
}: {
|
||||
cloud: CloudProvider
|
||||
service: CloudServiceType
|
||||
services?: CloudServiceDescriptor[]
|
||||
status?: CloudStatus
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: cloudQueryKeys.resources(cloud, service),
|
||||
queryFn: ({signal}) => listCloudResources(cloud, service, undefined, signal),
|
||||
enabled: hasAvailableService(services, service) && status?.runtime === 'reachable',
|
||||
})
|
||||
}
|
||||
|
||||
function hasAvailableService(services: CloudServiceDescriptor[] | undefined, service: CloudServiceType): boolean {
|
||||
return services?.some((item) => item.service === service && item.availability === 'available') ?? false
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
apiRequestEventBus,
|
||||
emitApiRequest,
|
||||
subscribeApiRequests,
|
||||
} from "@/telemetry/requestEventBus";
|
||||
export type { ApiRequestEvent, CloudProvider } from "@/telemetry/types";
|
||||
@@ -0,0 +1,114 @@
|
||||
export type ServiceStatus = 'healthy' | 'degraded' | 'unavailable' | 'unknown'
|
||||
|
||||
export type ServiceName =
|
||||
| 's3'
|
||||
| 'sqs'
|
||||
| 'dynamodb'
|
||||
| 'sns'
|
||||
| 'lambda'
|
||||
| 'eks'
|
||||
| 'secretsmanager'
|
||||
| 'cognito'
|
||||
| 'rds'
|
||||
| 'elasticache'
|
||||
| 'iam'
|
||||
| 'ssm'
|
||||
| 'kms'
|
||||
| 'cloudwatch'
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: ServiceName
|
||||
displayName: string
|
||||
status: ServiceStatus
|
||||
requestCount: number
|
||||
errorCount: number
|
||||
latencyP50Ms?: number
|
||||
lastActivity?: string
|
||||
}
|
||||
|
||||
export interface HealthReport {
|
||||
status: ServiceStatus
|
||||
services: ServiceInfo[]
|
||||
checkedAt: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface ResourceSummary {
|
||||
id: string
|
||||
name: string
|
||||
status?: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ServiceResourceSnapshot {
|
||||
service: ServiceName
|
||||
displayName: string
|
||||
implemented: boolean
|
||||
status: ServiceStatus
|
||||
count?: number
|
||||
latencyMs?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ConsoleOverview {
|
||||
checkedAt: string
|
||||
health: HealthReport
|
||||
resources: ServiceResourceSnapshot[]
|
||||
logGroupCount: number
|
||||
alarmCount: number
|
||||
metricCount: number
|
||||
totalResourceCount: number
|
||||
}
|
||||
|
||||
export interface CWLogGroup {
|
||||
name: string
|
||||
arn?: string
|
||||
retentionInDays?: number
|
||||
createdAt: number
|
||||
storedBytes: number
|
||||
metricFilterCount: number
|
||||
}
|
||||
|
||||
export interface CWLogStream {
|
||||
name: string
|
||||
createdAt?: number
|
||||
firstEventAt?: number
|
||||
lastEventAt?: number
|
||||
lastIngestionAt?: number
|
||||
storedBytes: number
|
||||
}
|
||||
|
||||
export interface CWLogEvent {
|
||||
id: string
|
||||
timestamp: number
|
||||
message: string
|
||||
ingestionTime?: number
|
||||
}
|
||||
|
||||
export interface CWAlarm {
|
||||
alarmName: string
|
||||
stateValue: 'ALARM' | 'OK' | 'INSUFFICIENT_DATA'
|
||||
stateReason?: string
|
||||
metricName?: string
|
||||
namespace?: string
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
export interface CWMetric {
|
||||
id: string
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions: Array<{ name: string; value: string }>
|
||||
}
|
||||
|
||||
export class FlociError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number,
|
||||
public readonly endpoint?: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'FlociError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
|
||||
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #151515;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #5559a7;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #141414;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #262425;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g class="st2">
|
||||
<g class="st2">
|
||||
<path class="st4" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
|
||||
<path class="st4" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
|
||||
<path class="st4" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
|
||||
<path class="st4" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st0" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
|
||||
<path class="st3" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
||||
<ellipse class="st1" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
|
||||
<ellipse class="st1" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-6 -4 200 153">
|
||||
<path fill="#151515" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
|
||||
<path fill="#141414" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
||||
<ellipse fill="#5559a7" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 851 B |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-6 -4 200 153">
|
||||
<path fill="#FFFFFF" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
|
||||
<path fill="#FFFFFF" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
||||
<ellipse fill="#7a7fd6" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 851 B |
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
|
||||
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #5559a7;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g class="st2">
|
||||
<g class="st2">
|
||||
<path class="st4" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
|
||||
<path class="st4" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
|
||||
<path class="st4" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
|
||||
<path class="st4" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st0" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
|
||||
<path class="st3" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
||||
<ellipse class="st1" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
|
||||
<ellipse class="st1" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,129 @@
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {Check, ChevronDown, UserRound} from 'lucide-react'
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
isAccountId,
|
||||
setAccountId,
|
||||
useAccountId,
|
||||
useAccountRecents,
|
||||
} from '@/lib/accountStore'
|
||||
|
||||
function formatAccount(id: string): string {
|
||||
// 000000000000 -> 0000-0000-0000, matching how AWS groups account ids.
|
||||
return id.replace(/(\d{4})(\d{4})(\d{4})/, '$1-$2-$3')
|
||||
}
|
||||
|
||||
export function AccountSwitcher() {
|
||||
const accountId = useAccountId()
|
||||
const recents = useAccountRecents()
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onClick = (event: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClick)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClick)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
function applyAccount(id: string) {
|
||||
if (!setAccountId(id)) {
|
||||
setError('Account id must be exactly 12 digits.')
|
||||
return
|
||||
}
|
||||
setDraft('')
|
||||
setError(null)
|
||||
setOpen(false)
|
||||
// Resources are account-scoped, so anything cached under the previous
|
||||
// account must not leak into the new view.
|
||||
void queryClient.invalidateQueries()
|
||||
}
|
||||
|
||||
function submitDraft() {
|
||||
const trimmed = draft.trim()
|
||||
if (!isAccountId(trimmed)) {
|
||||
setError('Account id must be exactly 12 digits.')
|
||||
return
|
||||
}
|
||||
applyAccount(trimmed)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="account-switcher" ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="account-trigger"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="Switch AWS account"
|
||||
aria-label={`Switch AWS account ${formatAccount(accountId)}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<UserRound size={14}/>
|
||||
<span className="account-meta">
|
||||
<span className="account-label">Account</span>
|
||||
<span className="account-value">{formatAccount(accountId)}</span>
|
||||
</span>
|
||||
<ChevronDown size={14}/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="account-popover" role="listbox">
|
||||
<div className="account-popover-title">Switch account</div>
|
||||
<div className="account-recents">
|
||||
{recents.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`account-option${id === accountId ? ' active' : ''}`}
|
||||
role="option"
|
||||
aria-selected={id === accountId}
|
||||
onClick={() => applyAccount(id)}
|
||||
>
|
||||
<span className="account-option-id">{formatAccount(id)}</span>
|
||||
{id === DEFAULT_ACCOUNT_ID && <span className="account-tag">default</span>}
|
||||
{id === accountId && <Check size={13}/>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="account-entry"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submitDraft()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={draft}
|
||||
inputMode="numeric"
|
||||
maxLength={12}
|
||||
placeholder="12-digit account id"
|
||||
aria-label="New account id"
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value.replace(/\D/g, ''))
|
||||
setError(null)
|
||||
}}
|
||||
/>
|
||||
<button type="submit" disabled={!isAccountId(draft.trim())}>
|
||||
Switch
|
||||
</button>
|
||||
</form>
|
||||
{error && <div className="account-error">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {ApiGatewayRoutePanel} from './ApiGatewayRoutePanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
createApiGatewayLambdaRoute: vi.fn(),
|
||||
listCloudResources: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => apiMocks)
|
||||
|
||||
const api: CloudResource = {
|
||||
id: 'api-123', name: 'orders', cloud: 'aws', service: 'apigateway', type: 'rest-api',
|
||||
region: null, createdAt: null, metadata: {},
|
||||
}
|
||||
|
||||
const lambda: CloudResource = {
|
||||
id: 'orders-handler', name: 'orders-handler', cloud: 'aws', service: 'serverless', type: 'lambda',
|
||||
region: null, createdAt: null, metadata: {},
|
||||
}
|
||||
|
||||
test('creates and displays a deployed Lambda proxy route', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.createApiGatewayLambdaRoute.mockResolvedValue({
|
||||
resourceId: 'resource-123', deploymentId: 'deployment-123', invokeUrl: 'http://localhost:4566/execute-api/api-123/dev/orders',
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await user.clear(screen.getByPlaceholderText('/orders'))
|
||||
await user.type(screen.getByPlaceholderText('/orders'), '/orders')
|
||||
await user.selectOptions(screen.getAllByRole('combobox')[1]!, 'orders-handler')
|
||||
await user.click(screen.getByRole('button', {name: 'Create and deploy route'}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.createApiGatewayLambdaRoute).toHaveBeenCalledWith('aws', 'api-123', {
|
||||
path: '/orders', method: 'GET', stage: 'dev', functionName: 'orders-handler',
|
||||
})
|
||||
})
|
||||
expect(await screen.findByText('Route deployed')).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Link2, Loader2, Rocket} from 'lucide-react'
|
||||
import {useMutation, useQuery} from '@tanstack/react-query'
|
||||
import {createApiGatewayLambdaRoute, listCloudResources} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
interface ApiGatewayRoutePanelProps {
|
||||
cloud: CloudProvider
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGatewayRoutePanelProps) {
|
||||
const [path, setPath] = useState('/hello')
|
||||
const [method, setMethod] = useState('GET')
|
||||
const [stage, setStage] = useState('dev')
|
||||
const [functionName, setFunctionName] = useState('')
|
||||
const functionsQuery = useQuery({
|
||||
queryKey: ['cloud-resources', cloud, 'serverless'],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal),
|
||||
enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setPath('/hello')
|
||||
setMethod('GET')
|
||||
setStage('dev')
|
||||
setFunctionName('')
|
||||
}, [resource?.id])
|
||||
|
||||
const routeMutation = useMutation({
|
||||
mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {path, method, stage, functionName}),
|
||||
})
|
||||
|
||||
if (!resource || resource.service !== 'apigateway') {
|
||||
return (
|
||||
<section className="table-panel">
|
||||
<div className="empty compact">
|
||||
<h3>Select a REST API</h3>
|
||||
<p>Select an API Gateway REST API to connect a route to a Lambda function.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
|
||||
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending
|
||||
|
||||
return (
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">REST API Route</p>
|
||||
<h3><Link2 size={15}/> Connect Lambda</h3>
|
||||
<p className="muted compact-text">Creates and deploys one Lambda proxy route to the selected REST API.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="resource-create-inline">
|
||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (canCreate) routeMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field">
|
||||
<span>Route path</span>
|
||||
<input className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders" required/>
|
||||
<small>One path segment, for example <code>/qrcode</code>.</small>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Method</span>
|
||||
<select className="input" value={method} onChange={(event) => setMethod(event.target.value)}>
|
||||
{['GET', 'POST', 'PUT', 'DELETE'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
<small>Lambda proxy method.</small>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Lambda function</span>
|
||||
<select className="input" value={functionName} onChange={(event) => setFunctionName(event.target.value)} required>
|
||||
<option value="">Select a Lambda function</option>
|
||||
{functions.map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}
|
||||
</select>
|
||||
<small>{functionsQuery.isLoading ? 'Loading functions...' : functions.length === 0 ? 'Create a Lambda function before adding a route.' : 'Receives this route\'s proxy event.'}</small>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Stage</span>
|
||||
<input className="input" value={stage} onChange={(event) => setStage(event.target.value)} placeholder="dev" required/>
|
||||
<small>Deployment stage name.</small>
|
||||
</label>
|
||||
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>
|
||||
{routeMutation.isPending ? <Loader2 size={13}/> : <Rocket size={13}/>}
|
||||
{routeMutation.isPending ? 'Deploying' : 'Create and deploy route'}
|
||||
</button>
|
||||
</form>
|
||||
{routeMutation.isError && <p className="error-text compact-text">{routeMutation.error instanceof Error ? routeMutation.error.message : 'Unable to create API route.'}</p>}
|
||||
{routeMutation.data && (
|
||||
<div className="apigateway-route-result">
|
||||
<strong>Route deployed</strong>
|
||||
<a href={routeMutation.data.invokeUrl} target="_blank" rel="noreferrer">{routeMutation.data.invokeUrl}</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {act, render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, describe, expect, test, vi} from 'vitest'
|
||||
import {AppConfigPanel} from './AppConfigPanel'
|
||||
import {DEFAULT_ACCOUNT_ID, getAccountId, setAccountId} from '@/lib/accountStore'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const cloudProxyMocks = vi.hoisted(() => ({
|
||||
listAppConfigEnvironments: vi.fn(),
|
||||
listAppConfigConfigurationProfiles: vi.fn(),
|
||||
listAppConfigDeploymentStrategies: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => ({
|
||||
createAppConfigConfigurationProfile: vi.fn(),
|
||||
createAppConfigDeploymentStrategy: vi.fn(),
|
||||
createAppConfigEnvironment: vi.fn(),
|
||||
createAppConfigHostedConfigurationVersion: vi.fn(),
|
||||
deleteAppConfigConfigurationProfile: vi.fn(),
|
||||
deleteAppConfigDeploymentStrategy: vi.fn(),
|
||||
deleteAppConfigEnvironment: vi.fn(),
|
||||
deleteAppConfigHostedConfigurationVersion: vi.fn(),
|
||||
getAppConfigDeployment: vi.fn(),
|
||||
getAppConfigHostedConfigurationVersion: vi.fn(),
|
||||
listAppConfigConfigurationProfiles: cloudProxyMocks.listAppConfigConfigurationProfiles,
|
||||
listAppConfigDeploymentStrategies: cloudProxyMocks.listAppConfigDeploymentStrategies,
|
||||
listAppConfigEnvironments: cloudProxyMocks.listAppConfigEnvironments,
|
||||
listAppConfigHostedConfigurationVersions: vi.fn(),
|
||||
startAppConfigDeployment: vi.fn(),
|
||||
}))
|
||||
|
||||
const application: CloudResource = {
|
||||
id: 'app-1',
|
||||
name: 'orders',
|
||||
cloud: 'aws',
|
||||
service: 'configuration',
|
||||
type: 'appconfig-application',
|
||||
region: 'us-east-1',
|
||||
createdAt: null,
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
describe('AppConfigPanel account isolation', () => {
|
||||
beforeEach(() => {
|
||||
setAccountId(DEFAULT_ACCOUNT_ID)
|
||||
cloudProxyMocks.listAppConfigEnvironments.mockReset()
|
||||
cloudProxyMocks.listAppConfigConfigurationProfiles.mockReset()
|
||||
cloudProxyMocks.listAppConfigDeploymentStrategies.mockReset()
|
||||
cloudProxyMocks.listAppConfigConfigurationProfiles.mockResolvedValue([])
|
||||
cloudProxyMocks.listAppConfigDeploymentStrategies.mockResolvedValue([])
|
||||
cloudProxyMocks.listAppConfigEnvironments.mockImplementation(async () => [{
|
||||
id: `environment-${getAccountId()}`,
|
||||
applicationId: application.id,
|
||||
name: `environment-${getAccountId()}`,
|
||||
description: null,
|
||||
state: 'READY_FOR_DEPLOYMENT',
|
||||
}])
|
||||
})
|
||||
|
||||
test('loads a fresh cache and resets account-scoped form state after an account switch', async () => {
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppConfigPanel cloud="aws" resource={application} runtimeReachable/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(await screen.findAllByText(`environment-${DEFAULT_ACCOUNT_ID}`)).toHaveLength(2)
|
||||
const environmentName = screen.getByPlaceholderText('Environment name')
|
||||
await user.type(environmentName, 'private-draft')
|
||||
expect(environmentName).toHaveValue('private-draft')
|
||||
|
||||
await act(async () => {
|
||||
setAccountId('111111111111')
|
||||
})
|
||||
|
||||
expect(await screen.findAllByText('environment-111111111111')).toHaveLength(2)
|
||||
expect(screen.queryAllByText(`environment-${DEFAULT_ACCOUNT_ID}`)).toHaveLength(0)
|
||||
expect(screen.getByPlaceholderText('Environment name')).toHaveValue('')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(cloudProxyMocks.listAppConfigEnvironments).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
expect(queryClient.getQueryData(['appconfig-environments', DEFAULT_ACCOUNT_ID, 'aws', application.id])).toBeDefined()
|
||||
expect(queryClient.getQueryData(['appconfig-environments', '111111111111', 'aws', application.id])).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,548 @@
|
||||
import {FormEvent, useEffect, useMemo, useState} from 'react'
|
||||
import {Plus, RefreshCw, Rocket, SlidersHorizontal, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {
|
||||
createAppConfigConfigurationProfile,
|
||||
createAppConfigDeploymentStrategy,
|
||||
createAppConfigEnvironment,
|
||||
createAppConfigHostedConfigurationVersion,
|
||||
deleteAppConfigConfigurationProfile,
|
||||
deleteAppConfigDeploymentStrategy,
|
||||
deleteAppConfigEnvironment,
|
||||
deleteAppConfigHostedConfigurationVersion,
|
||||
getAppConfigDeployment,
|
||||
getAppConfigHostedConfigurationVersion,
|
||||
listAppConfigConfigurationProfiles,
|
||||
listAppConfigDeploymentStrategies,
|
||||
listAppConfigEnvironments,
|
||||
listAppConfigHostedConfigurationVersions,
|
||||
startAppConfigDeployment,
|
||||
} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {AppConfigDeployment, CloudResource} from '@/types/resource'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
|
||||
const TERMINAL_DEPLOYMENT_STATES = new Set(['COMPLETE', 'ROLLED_BACK', 'STOPPED'])
|
||||
const PROFILE_TYPES = ['AWS.Freeform', 'AWS.AppConfig.FeatureFlags']
|
||||
|
||||
interface AppConfigPanelProps {
|
||||
cloud: CloudProvider
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
export function AppConfigPanel({cloud, resource, runtimeReachable}: AppConfigPanelProps) {
|
||||
const accountId = useAccountId()
|
||||
|
||||
return (
|
||||
<AccountScopedAppConfigPanel
|
||||
key={`${accountId}:${cloud}:${resource?.id ?? ''}`}
|
||||
accountId={accountId}
|
||||
cloud={cloud}
|
||||
resource={resource}
|
||||
runtimeReachable={runtimeReachable}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface AccountScopedAppConfigPanelProps extends AppConfigPanelProps {
|
||||
accountId: string
|
||||
}
|
||||
|
||||
function AccountScopedAppConfigPanel({accountId, cloud, resource, runtimeReachable}: AccountScopedAppConfigPanelProps) {
|
||||
const qc = useQueryClient()
|
||||
const applicationId = resource?.id
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string>()
|
||||
const [selectedVersionNumber, setSelectedVersionNumber] = useState<number>()
|
||||
const [environmentName, setEnvironmentName] = useState('')
|
||||
const [profileName, setProfileName] = useState('')
|
||||
const [profileType, setProfileType] = useState(PROFILE_TYPES[0])
|
||||
const [versionContent, setVersionContent] = useState('')
|
||||
const [versionContentType, setVersionContentType] = useState('application/json')
|
||||
const [versionDescription, setVersionDescription] = useState('')
|
||||
const [strategyName, setStrategyName] = useState('')
|
||||
const [strategyBake, setStrategyBake] = useState('')
|
||||
const [deployEnvironmentId, setDeployEnvironmentId] = useState('')
|
||||
const [deployProfileId, setDeployProfileId] = useState('')
|
||||
const [deployVersion, setDeployVersion] = useState('')
|
||||
const [deployStrategyId, setDeployStrategyId] = useState('')
|
||||
const [confirmProfile, setConfirmProfile] = useState<string | null>(null)
|
||||
const [confirmVersion, setConfirmVersion] = useState<number | null>(null)
|
||||
const [confirmStrategy, setConfirmStrategy] = useState<string | null>(null)
|
||||
const [confirmEnvironment, setConfirmEnvironment] = useState<string | null>(null)
|
||||
const [activeDeployment, setActiveDeployment] = useState<AppConfigDeployment>()
|
||||
|
||||
const environmentsKey = useMemo(
|
||||
() => ['appconfig-environments', accountId, cloud, applicationId],
|
||||
[accountId, cloud, applicationId],
|
||||
)
|
||||
const profilesKey = useMemo(
|
||||
() => ['appconfig-profiles', accountId, cloud, applicationId],
|
||||
[accountId, cloud, applicationId],
|
||||
)
|
||||
const versionsKey = useMemo(
|
||||
() => ['appconfig-versions', accountId, cloud, applicationId, selectedProfileId],
|
||||
[accountId, cloud, applicationId, selectedProfileId],
|
||||
)
|
||||
|
||||
const environmentsQuery = useQuery({
|
||||
queryKey: environmentsKey,
|
||||
queryFn: ({signal}) => listAppConfigEnvironments(cloud, applicationId ?? '', signal),
|
||||
enabled: Boolean(applicationId) && runtimeReachable,
|
||||
})
|
||||
const profilesQuery = useQuery({
|
||||
queryKey: profilesKey,
|
||||
queryFn: ({signal}) => listAppConfigConfigurationProfiles(cloud, applicationId ?? '', signal),
|
||||
enabled: Boolean(applicationId) && runtimeReachable,
|
||||
})
|
||||
const versionsQuery = useQuery({
|
||||
queryKey: versionsKey,
|
||||
queryFn: ({signal}) => listAppConfigHostedConfigurationVersions(cloud, applicationId ?? '', selectedProfileId ?? '', signal),
|
||||
enabled: Boolean(applicationId && selectedProfileId) && runtimeReachable,
|
||||
})
|
||||
const deployVersionsQuery = useQuery({
|
||||
queryKey: ['appconfig-versions', accountId, cloud, applicationId, deployProfileId],
|
||||
queryFn: ({signal}) => listAppConfigHostedConfigurationVersions(cloud, applicationId ?? '', deployProfileId ?? '', signal),
|
||||
enabled: Boolean(applicationId && deployProfileId) && runtimeReachable,
|
||||
})
|
||||
const strategiesQuery = useQuery({
|
||||
queryKey: ['appconfig-strategies', accountId, cloud],
|
||||
queryFn: ({signal}) => listAppConfigDeploymentStrategies(cloud, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const versionContentQuery = useQuery({
|
||||
queryKey: ['appconfig-version-content', accountId, cloud, applicationId, selectedProfileId, selectedVersionNumber],
|
||||
queryFn: ({signal}) => getAppConfigHostedConfigurationVersion(cloud, applicationId ?? '', selectedProfileId ?? '', selectedVersionNumber ?? 0, signal),
|
||||
enabled: Boolean(applicationId && selectedProfileId && selectedVersionNumber) && runtimeReachable,
|
||||
})
|
||||
const deploymentQuery = useQuery({
|
||||
queryKey: ['appconfig-deployment', accountId, cloud, applicationId, activeDeployment?.environmentId, activeDeployment?.deploymentNumber],
|
||||
queryFn: ({signal}) => getAppConfigDeployment(cloud, applicationId ?? '', activeDeployment?.environmentId ?? '', activeDeployment?.deploymentNumber ?? 0, signal),
|
||||
enabled: Boolean(applicationId && activeDeployment) && runtimeReachable,
|
||||
refetchInterval: (query) => (TERMINAL_DEPLOYMENT_STATES.has(query.state.data?.state ?? '') ? false : 2000),
|
||||
})
|
||||
|
||||
const createEnvironmentMut = useMutation({
|
||||
mutationFn: () => createAppConfigEnvironment(cloud, applicationId ?? '', {name: environmentName}),
|
||||
onSuccess: () => {
|
||||
setEnvironmentName('')
|
||||
void qc.invalidateQueries({queryKey: environmentsKey})
|
||||
},
|
||||
})
|
||||
const deleteEnvironmentMut = useMutation({
|
||||
mutationFn: (environmentId: string) => deleteAppConfigEnvironment(cloud, applicationId ?? '', environmentId),
|
||||
onSuccess: (_, environmentId) => {
|
||||
if (deployEnvironmentId === environmentId) setDeployEnvironmentId('')
|
||||
if (activeDeployment?.environmentId === environmentId) setActiveDeployment(undefined)
|
||||
setConfirmEnvironment(null)
|
||||
void qc.invalidateQueries({queryKey: environmentsKey})
|
||||
},
|
||||
})
|
||||
const createProfileMut = useMutation({
|
||||
mutationFn: () => createAppConfigConfigurationProfile(cloud, applicationId ?? '', {
|
||||
name: profileName,
|
||||
locationUri: 'hosted',
|
||||
type: profileType,
|
||||
}),
|
||||
onSuccess: (profile) => {
|
||||
setProfileName('')
|
||||
setSelectedProfileId(profile.id)
|
||||
void qc.invalidateQueries({queryKey: profilesKey})
|
||||
},
|
||||
})
|
||||
const deleteProfileMut = useMutation({
|
||||
mutationFn: (profileId: string) => deleteAppConfigConfigurationProfile(cloud, applicationId ?? '', profileId),
|
||||
onSuccess: (_, profileId) => {
|
||||
if (selectedProfileId === profileId) setSelectedProfileId(undefined)
|
||||
if (deployProfileId === profileId) {
|
||||
setDeployProfileId('')
|
||||
setDeployVersion('')
|
||||
}
|
||||
setConfirmProfile(null)
|
||||
void qc.invalidateQueries({queryKey: profilesKey})
|
||||
},
|
||||
})
|
||||
const createVersionMut = useMutation({
|
||||
mutationFn: () => createAppConfigHostedConfigurationVersion(cloud, applicationId ?? '', selectedProfileId ?? '', {
|
||||
content: versionContent,
|
||||
contentType: versionContentType,
|
||||
...(versionDescription.trim() ? {description: versionDescription} : {}),
|
||||
}),
|
||||
onSuccess: (version) => {
|
||||
setSelectedVersionNumber(version.versionNumber)
|
||||
setVersionContent('')
|
||||
setVersionDescription('')
|
||||
void qc.invalidateQueries({queryKey: versionsKey})
|
||||
void qc.invalidateQueries({queryKey: ['appconfig-versions', accountId, cloud, applicationId, deployProfileId]})
|
||||
},
|
||||
})
|
||||
const deleteVersionMut = useMutation({
|
||||
mutationFn: (versionNumber: number) => deleteAppConfigHostedConfigurationVersion(cloud, applicationId ?? '', selectedProfileId ?? '', versionNumber),
|
||||
onSuccess: (_, versionNumber) => {
|
||||
if (selectedVersionNumber === versionNumber) setSelectedVersionNumber(undefined)
|
||||
if (deployVersion === String(versionNumber)) setDeployVersion('')
|
||||
setConfirmVersion(null)
|
||||
void qc.invalidateQueries({queryKey: versionsKey})
|
||||
},
|
||||
})
|
||||
const createStrategyMut = useMutation({
|
||||
mutationFn: () => createAppConfigDeploymentStrategy(cloud, {
|
||||
name: strategyName,
|
||||
deploymentDurationInMinutes: 0,
|
||||
growthFactor: 100,
|
||||
...(strategyBake.trim() ? {finalBakeTimeInMinutes: Number(strategyBake)} : {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setStrategyName('')
|
||||
setStrategyBake('')
|
||||
void qc.invalidateQueries({queryKey: ['appconfig-strategies', accountId, cloud]})
|
||||
},
|
||||
})
|
||||
const deleteStrategyMut = useMutation({
|
||||
mutationFn: (strategyId: string) => deleteAppConfigDeploymentStrategy(cloud, strategyId),
|
||||
onSuccess: (_, strategyId) => {
|
||||
if (deployStrategyId === strategyId) setDeployStrategyId('')
|
||||
setConfirmStrategy(null)
|
||||
void qc.invalidateQueries({queryKey: ['appconfig-strategies', accountId, cloud]})
|
||||
},
|
||||
})
|
||||
const startDeploymentMut = useMutation({
|
||||
mutationFn: () => startAppConfigDeployment(cloud, applicationId ?? '', deployEnvironmentId, {
|
||||
configurationProfileId: deployProfileId,
|
||||
configurationVersion: deployVersion,
|
||||
deploymentStrategyId: deployStrategyId,
|
||||
}),
|
||||
onSuccess: (deployment) => setActiveDeployment(deployment),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProfileId(undefined)
|
||||
setSelectedVersionNumber(undefined)
|
||||
setActiveDeployment(undefined)
|
||||
setDeployEnvironmentId('')
|
||||
setDeployProfileId('')
|
||||
setDeployVersion('')
|
||||
setDeployStrategyId('')
|
||||
}, [cloud, applicationId])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedVersionNumber(undefined)
|
||||
}, [selectedProfileId])
|
||||
|
||||
if (cloud !== 'aws') return null
|
||||
|
||||
if (!applicationId) {
|
||||
return (
|
||||
<section className="cosmos-panel appconfig-panel">
|
||||
<div className="empty compact">
|
||||
<h3>Select an application</h3>
|
||||
<p>Environments, configuration profiles, hosted versions, and deployments are loaded after an application is selected.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const environments = environmentsQuery.data ?? []
|
||||
const profiles = profilesQuery.data ?? []
|
||||
const versions = versionsQuery.data ?? []
|
||||
const strategies = strategiesQuery.data ?? []
|
||||
const deployment = deploymentQuery.data ?? activeDeployment
|
||||
const canDeploy = Boolean(deployEnvironmentId && deployProfileId && deployVersion && deployStrategyId) && runtimeReachable
|
||||
|
||||
function submitEnvironment(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (environmentName.trim()) createEnvironmentMut.mutate()
|
||||
}
|
||||
|
||||
function submitProfile(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (profileName.trim()) createProfileMut.mutate()
|
||||
}
|
||||
|
||||
function submitVersion(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (versionContent.trim()) createVersionMut.mutate()
|
||||
}
|
||||
|
||||
function submitStrategy(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (strategyName.trim()) createStrategyMut.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="cosmos-panel appconfig-panel">
|
||||
<div className="cosmos-column">
|
||||
<ColumnHeader eyebrow="Environments" title={resource?.name ?? ''} detail={`${environments.length} environments`}/>
|
||||
<form className="cosmos-inline-form" onSubmit={submitEnvironment}>
|
||||
<input className="input" value={environmentName} onChange={(event) => setEnvironmentName(event.target.value)} placeholder="Environment name" disabled={!runtimeReachable}/>
|
||||
<button className="button" type="submit" disabled={!runtimeReachable || createEnvironmentMut.isPending || !environmentName.trim()}>
|
||||
<Plus size={14}/>
|
||||
{createEnvironmentMut.isPending ? 'Creating' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
{createEnvironmentMut.error instanceof Error && <div className="form-error">{createEnvironmentMut.error.message}</div>}
|
||||
{environmentsQuery.error instanceof Error && <div className="form-error">{environmentsQuery.error.message}</div>}
|
||||
{deleteEnvironmentMut.error instanceof Error && <div className="form-error">{deleteEnvironmentMut.error.message}</div>}
|
||||
<div className="cosmos-list">
|
||||
{environmentsQuery.isLoading && <div className="muted padded">Loading environments</div>}
|
||||
{!environmentsQuery.isLoading && environments.length === 0 && <div className="muted padded">No environments</div>}
|
||||
{environments.map((environment) => (
|
||||
<button
|
||||
key={environment.id}
|
||||
className={`cosmos-list-row ${deployEnvironmentId === environment.id ? 'selected' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setDeployEnvironmentId(environment.id)}
|
||||
>
|
||||
<span>
|
||||
<strong>{environment.name}</strong>
|
||||
<small>{[environment.state, environment.description].filter(Boolean).join(' · ') || 'No details'}</small>
|
||||
</span>
|
||||
{confirmEnvironment === environment.id ? (
|
||||
<em
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
deleteEnvironmentMut.mutate(environment.id)
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</em>
|
||||
) : (
|
||||
<Trash2
|
||||
size={13}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setConfirmEnvironment(environment.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cosmos-column">
|
||||
<ColumnHeader eyebrow="Configuration profiles" title="Profiles" detail={`${profiles.length} profiles`}/>
|
||||
<form className="cosmos-inline-form" onSubmit={submitProfile}>
|
||||
<input className="input" value={profileName} onChange={(event) => setProfileName(event.target.value)} placeholder="Profile name" disabled={!runtimeReachable}/>
|
||||
<select className="input" value={profileType} onChange={(event) => setProfileType(event.target.value)} disabled={!runtimeReachable}>
|
||||
{PROFILE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
<button className="button" type="submit" disabled={!runtimeReachable || createProfileMut.isPending || !profileName.trim()}>
|
||||
<Plus size={14}/>
|
||||
{createProfileMut.isPending ? 'Creating' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
{createProfileMut.error instanceof Error && <div className="form-error">{createProfileMut.error.message}</div>}
|
||||
{profilesQuery.error instanceof Error && <div className="form-error">{profilesQuery.error.message}</div>}
|
||||
{deleteProfileMut.error instanceof Error && <div className="form-error">{deleteProfileMut.error.message}</div>}
|
||||
<div className="cosmos-list">
|
||||
{profilesQuery.isLoading && <div className="muted padded">Loading profiles</div>}
|
||||
{!profilesQuery.isLoading && profiles.length === 0 && <div className="muted padded">No configuration profiles</div>}
|
||||
{profiles.map((profile) => (
|
||||
<button
|
||||
key={profile.id}
|
||||
className={`cosmos-list-row ${selectedProfileId === profile.id ? 'selected' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setSelectedProfileId(profile.id)}
|
||||
>
|
||||
<span>
|
||||
<strong>{profile.name}</strong>
|
||||
<small>{profile.type ?? 'Unknown type'}</small>
|
||||
</span>
|
||||
{confirmProfile === profile.id ? (
|
||||
<em
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
deleteProfileMut.mutate(profile.id)
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</em>
|
||||
) : (
|
||||
<Trash2
|
||||
size={13}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setConfirmProfile(profile.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cosmos-column cosmos-column--wide">
|
||||
<ColumnHeader
|
||||
eyebrow="Hosted configuration versions"
|
||||
title={profiles.find((profile) => profile.id === selectedProfileId)?.name ?? 'Select profile'}
|
||||
detail={`${versions.length} versions`}
|
||||
/>
|
||||
<form className="cosmos-inline-form" onSubmit={submitVersion}>
|
||||
<input className="input" value={versionContentType} onChange={(event) => setVersionContentType(event.target.value)} placeholder="Content type" disabled={!runtimeReachable}/>
|
||||
<input className="input" value={versionDescription} onChange={(event) => setVersionDescription(event.target.value)} placeholder="Description (optional)" disabled={!runtimeReachable}/>
|
||||
<button className="button" type="submit" disabled={!selectedProfileId || !runtimeReachable || createVersionMut.isPending || !versionContent.trim()}>
|
||||
<Plus size={14}/>
|
||||
{createVersionMut.isPending ? 'Saving' : 'Create version'}
|
||||
</button>
|
||||
</form>
|
||||
<textarea
|
||||
className="textarea code-textarea"
|
||||
value={versionContent}
|
||||
onChange={(event) => setVersionContent(event.target.value)}
|
||||
placeholder={selectedProfileId ? 'Configuration content' : 'Select a profile to add a version'}
|
||||
spellCheck={false}
|
||||
disabled={!selectedProfileId || !runtimeReachable}
|
||||
/>
|
||||
{createVersionMut.error instanceof Error && <div className="form-error">{createVersionMut.error.message}</div>}
|
||||
{versionsQuery.error instanceof Error && <div className="form-error">{versionsQuery.error.message}</div>}
|
||||
{deleteVersionMut.error instanceof Error && <div className="form-error">{deleteVersionMut.error.message}</div>}
|
||||
<div className="cosmos-items-table">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>Content type</th>
|
||||
<th>Description</th>
|
||||
<th aria-label="Actions"/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{versions.map((version) => (
|
||||
<tr key={version.id} className={selectedVersionNumber === version.versionNumber ? 'selected' : ''}>
|
||||
<td onClick={() => setSelectedVersionNumber(version.versionNumber)}>{version.versionNumber}</td>
|
||||
<td onClick={() => setSelectedVersionNumber(version.versionNumber)}>{version.contentType ?? '-'}</td>
|
||||
<td onClick={() => setSelectedVersionNumber(version.versionNumber)}>{version.description ?? '-'}</td>
|
||||
<td className="table-actions">
|
||||
{confirmVersion === version.versionNumber ? (
|
||||
<button className="button danger compact" type="button" onClick={() => deleteVersionMut.mutate(version.versionNumber)}>Confirm</button>
|
||||
) : (
|
||||
<button className="icon-btn danger" type="button" title={`Delete version ${version.versionNumber}`} onClick={() => setConfirmVersion(version.versionNumber)}>
|
||||
<Trash2 size={13}/>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{selectedProfileId && !versionsQuery.isLoading && versions.length === 0 && (
|
||||
<div className="empty compact"><h3>No hosted versions</h3><p>Create a version to store configuration content.</p></div>
|
||||
)}
|
||||
</div>
|
||||
{selectedVersionNumber !== undefined && (
|
||||
<>
|
||||
<div className="muted padded">Version {selectedVersionNumber} content</div>
|
||||
{versionContentQuery.isLoading && <div className="muted padded">Loading content</div>}
|
||||
{versionContentQuery.error instanceof Error && <div className="form-error">{versionContentQuery.error.message}</div>}
|
||||
{versionContentQuery.data?.content != null && (
|
||||
<pre className="cosmos-query-result">{versionContentQuery.data.content}</pre>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cosmos-column">
|
||||
<ColumnHeader eyebrow="Deploy" title="Deployment" detail="Deploys a hosted version to an environment"/>
|
||||
<form className="cosmos-inline-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (canDeploy) startDeploymentMut.mutate()
|
||||
}}>
|
||||
<select className="input" value={deployEnvironmentId} onChange={(event) => setDeployEnvironmentId(event.target.value)} disabled={!runtimeReachable}>
|
||||
<option value="">Select environment</option>
|
||||
{environments.map((environment) => <option key={environment.id} value={environment.id}>{environment.name}</option>)}
|
||||
</select>
|
||||
<select className="input" value={deployProfileId} onChange={(event) => {
|
||||
setDeployProfileId(event.target.value)
|
||||
setDeployVersion('')
|
||||
}} disabled={!runtimeReachable}>
|
||||
<option value="">Select profile</option>
|
||||
{profiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
|
||||
</select>
|
||||
<select className="input" value={deployVersion} onChange={(event) => setDeployVersion(event.target.value)} disabled={!runtimeReachable || !deployProfileId}>
|
||||
<option value="">Select version</option>
|
||||
{(deployVersionsQuery.data ?? []).map((version) => (
|
||||
<option key={version.id} value={String(version.versionNumber)}>v{version.versionNumber}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="input" value={deployStrategyId} onChange={(event) => setDeployStrategyId(event.target.value)} disabled={!runtimeReachable}>
|
||||
<option value="">Select strategy</option>
|
||||
{strategies.map((strategy) => <option key={strategy.id} value={strategy.id}>{strategy.name}</option>)}
|
||||
</select>
|
||||
<button className="button primary" type="submit" disabled={!canDeploy || startDeploymentMut.isPending}>
|
||||
<Rocket size={14}/>
|
||||
{startDeploymentMut.isPending ? 'Deploying' : 'Start deployment'}
|
||||
</button>
|
||||
</form>
|
||||
{startDeploymentMut.error instanceof Error && <div className="form-error">{startDeploymentMut.error.message}</div>}
|
||||
{deployment && (
|
||||
<div className="cosmos-list">
|
||||
<div className="cosmos-list-row">
|
||||
<span>
|
||||
<strong>Deployment #{deployment.deploymentNumber}</strong>
|
||||
<small>
|
||||
{deployment.state ?? 'Unknown state'}
|
||||
{deployment.percentageComplete !== null ? ` · ${deployment.percentageComplete}%` : ''}
|
||||
</small>
|
||||
</span>
|
||||
{!TERMINAL_DEPLOYMENT_STATES.has(deployment.state ?? '') && (
|
||||
<button className="icon-btn" type="button" title="Refresh deployment" onClick={() => deploymentQuery.refetch()}>
|
||||
<RefreshCw size={13}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ColumnHeader eyebrow="Strategies" title="Deployment strategies" detail={`${strategies.length} strategies`}/>
|
||||
<form className="cosmos-inline-form" onSubmit={submitStrategy}>
|
||||
<input className="input" value={strategyName} onChange={(event) => setStrategyName(event.target.value)} placeholder="Immediate strategy name" disabled={!runtimeReachable}/>
|
||||
<input className="input" type="number" min={0} value={strategyBake} onChange={(event) => setStrategyBake(event.target.value)} placeholder="Bake min" disabled={!runtimeReachable}/>
|
||||
<button className="button" type="submit" disabled={!runtimeReachable || createStrategyMut.isPending || !strategyName.trim()}>
|
||||
<Plus size={14}/>
|
||||
{createStrategyMut.isPending ? 'Creating' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
{createStrategyMut.error instanceof Error && <div className="form-error">{createStrategyMut.error.message}</div>}
|
||||
{strategiesQuery.error instanceof Error && <div className="form-error">{strategiesQuery.error.message}</div>}
|
||||
{deleteStrategyMut.error instanceof Error && <div className="form-error">{deleteStrategyMut.error.message}</div>}
|
||||
<div className="cosmos-list">
|
||||
{strategiesQuery.isLoading && <div className="muted padded">Loading strategies</div>}
|
||||
{!strategiesQuery.isLoading && strategies.length === 0 && <div className="muted padded">No deployment strategies</div>}
|
||||
{strategies.map((strategy) => (
|
||||
<div key={strategy.id} className="cosmos-list-row">
|
||||
<span>
|
||||
<strong>{strategy.name}</strong>
|
||||
<small>
|
||||
{strategy.deploymentDurationInMinutes ?? 0} min · growth {strategy.growthFactor ?? 0}% · bake {strategy.finalBakeTimeInMinutes ?? 0} min
|
||||
</small>
|
||||
</span>
|
||||
{confirmStrategy === strategy.id ? (
|
||||
<em role="button" tabIndex={0} onClick={() => deleteStrategyMut.mutate(strategy.id)}>Confirm</em>
|
||||
) : (
|
||||
<Trash2 size={13} onClick={() => setConfirmStrategy(strategy.id)}/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ColumnHeader({eyebrow, title, detail}: {eyebrow: string; title: string; detail: string}) {
|
||||
return (
|
||||
<div className="cosmos-panel-header">
|
||||
<SlidersHorizontal size={15}/>
|
||||
<span>
|
||||
<small>{eyebrow}</small>
|
||||
<strong>{title}</strong>
|
||||
<em>{detail}</em>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Code2, Database, Play, Plug, RefreshCw, Table2, Unplug } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useAccountId } from "@/lib/accountStore";
|
||||
import {
|
||||
listSqlDatabases,
|
||||
listSqlTables,
|
||||
querySql,
|
||||
} from "@/api/cloudProxyClient";
|
||||
import type { CloudProvider } from "@/types/cloud";
|
||||
import type {
|
||||
CloudResource,
|
||||
SqlCredentials,
|
||||
SqlDatabase,
|
||||
SqlEngine,
|
||||
SqlQueryResult,
|
||||
SqlTable,
|
||||
} from "@/types/resource";
|
||||
|
||||
const SQL_SERVER_DEFAULT_QUERY = `SELECT
|
||||
DB_NAME() AS database_name,
|
||||
@@SERVERNAME AS server_name,
|
||||
@@VERSION AS version;`;
|
||||
const POSTGRES_DEFAULT_QUERY = `SELECT
|
||||
current_database() AS database_name,
|
||||
current_user AS username,
|
||||
version();`;
|
||||
|
||||
interface AzureSqlPanelProps {
|
||||
cloud: CloudProvider;
|
||||
resource?: CloudResource;
|
||||
runtimeReachable: boolean;
|
||||
}
|
||||
|
||||
export function AzureSqlPanel({
|
||||
cloud,
|
||||
resource,
|
||||
runtimeReachable,
|
||||
}: AzureSqlPanelProps) {
|
||||
const accountId = useAccountId();
|
||||
const serverId = resource?.id;
|
||||
const engine: SqlEngine =
|
||||
resource?.type === "postgres-flexible-server" ? "postgresql" : "azure-sql";
|
||||
const defaultDatabase = engine === "postgresql" ? "postgres" : "master";
|
||||
const defaultQuery =
|
||||
engine === "postgresql" ? POSTGRES_DEFAULT_QUERY : SQL_SERVER_DEFAULT_QUERY;
|
||||
const administratorLogin = metadataString(
|
||||
resource?.metadata.administratorLogin,
|
||||
);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [databases, setDatabases] = useState<SqlDatabase[]>([]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedDatabase = searchParams.get("database") || defaultDatabase;
|
||||
const tableSchema = searchParams.get("schema");
|
||||
const tableName = searchParams.get("table");
|
||||
const [queryText, setQueryText] = useState(defaultQuery);
|
||||
|
||||
const credentials: SqlCredentials = { username, password };
|
||||
const databaseExists = databases.some((database) => database.name === selectedDatabase);
|
||||
|
||||
const tablesQuery = useQuery({
|
||||
queryKey: ['sql-tables', accountId, cloud, serverId, engine, selectedDatabase, username],
|
||||
queryFn: ({signal}) => listSqlTables(cloud, serverId ?? "", engine, selectedDatabase, credentials, signal),
|
||||
enabled: connected && databaseExists && runtimeReachable,
|
||||
gcTime: 0,
|
||||
});
|
||||
const tables = connected && databaseExists ? tablesQuery.data ?? [] : [];
|
||||
const selectedTable = tables.find((table) => table.schema === tableSchema && table.name === tableName);
|
||||
const previewQuery = selectedTable && tablePreviewQuery(selectedTable, engine);
|
||||
|
||||
const databasesMut = useMutation({
|
||||
mutationFn: () =>
|
||||
listSqlDatabases(cloud, serverId ?? "", engine, credentials),
|
||||
});
|
||||
|
||||
const queryMut = useMutation({
|
||||
mutationFn: ({ database, query }: { database: string; query: string }) =>
|
||||
querySql(cloud, serverId ?? "", engine, database, credentials, query),
|
||||
});
|
||||
const {mutate: executeQuery, reset: resetQuery} = queryMut;
|
||||
|
||||
useEffect(() => {
|
||||
resetQuery();
|
||||
if (!connected || !previewQuery) {
|
||||
setQueryText(defaultQuery);
|
||||
return;
|
||||
}
|
||||
setQueryText(previewQuery);
|
||||
executeQuery({database: selectedDatabase, query: previewQuery});
|
||||
}, [connected, selectedDatabase, previewQuery, defaultQuery, executeQuery, resetQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(administratorLogin);
|
||||
setPassword("");
|
||||
setConnected(false);
|
||||
setDatabases([]);
|
||||
setQueryText(defaultQuery);
|
||||
}, [administratorLogin, cloud, defaultDatabase, defaultQuery, serverId]);
|
||||
|
||||
if (cloud !== "azure") return null;
|
||||
|
||||
if (!serverId) {
|
||||
return (
|
||||
<section className="sql-panel">
|
||||
<div className="empty compact">
|
||||
<h3>Select an Azure database server</h3>
|
||||
<p>Databases, tables, rows, and SQL queries load after connection.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function connect(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!username || !password) return;
|
||||
queryMut.reset();
|
||||
loadDatabases();
|
||||
}
|
||||
|
||||
function loadDatabases() {
|
||||
databasesMut.mutate(undefined, {
|
||||
onSuccess: (items) => {
|
||||
setDatabases(items);
|
||||
setConnected(true);
|
||||
if (!searchParams.has('database')) {
|
||||
const database = items.find((item) => item.name === defaultDatabase) ?? items[0];
|
||||
if (database) selectDatabase(database);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
setConnected(false);
|
||||
setPassword("");
|
||||
setDatabases([]);
|
||||
databasesMut.reset();
|
||||
queryMut.reset();
|
||||
}
|
||||
|
||||
function selectDatabase(database: SqlDatabase) {
|
||||
setSearchParams((previous) => {
|
||||
const next = new URLSearchParams(previous);
|
||||
next.set('database', database.name);
|
||||
next.delete('schema');
|
||||
next.delete('table');
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function previewTable(table: SqlTable) {
|
||||
if (table.schema === tableSchema && table.name === tableName) {
|
||||
const query = tablePreviewQuery(table, engine);
|
||||
setQueryText(query);
|
||||
queryMut.mutate({database: selectedDatabase, query});
|
||||
return;
|
||||
}
|
||||
setSearchParams((previous) => {
|
||||
const next = new URLSearchParams(previous);
|
||||
next.set('database', selectedDatabase);
|
||||
next.set('schema', table.schema);
|
||||
next.set('table', table.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function runQuery() {
|
||||
if (!queryText.trim()) return;
|
||||
queryMut.mutate({ database: selectedDatabase, query: queryText });
|
||||
}
|
||||
|
||||
const connectionError =
|
||||
databasesMut.error instanceof Error ? databasesMut.error.message : null;
|
||||
|
||||
return (
|
||||
<section className="sql-panel">
|
||||
<form className="sql-connection-bar" onSubmit={connect}>
|
||||
<div className="sql-connection-title">
|
||||
<Plug size={16} />
|
||||
<span>
|
||||
<small>{engine === "postgresql" ? "POSTGRESQL DATA PLANE" : "SQL DATA PLANE"}</small>
|
||||
<strong>{resource.name}</strong>
|
||||
<em>{metadataString(resource.metadata.fullyQualifiedDomainName)}</em>
|
||||
</span>
|
||||
</div>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input
|
||||
className="input"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
disabled={connected || !runtimeReachable}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
disabled={connected || !runtimeReachable}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
{connected ? (
|
||||
<button className="button" type="button" onClick={disconnect}>
|
||||
<Unplug size={14} />
|
||||
Disconnect
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="button primary"
|
||||
type="submit"
|
||||
disabled={
|
||||
!runtimeReachable ||
|
||||
databasesMut.isPending ||
|
||||
!username ||
|
||||
!password
|
||||
}
|
||||
>
|
||||
<Plug size={14} />
|
||||
{databasesMut.isPending ? "Connecting" : "Connect"}
|
||||
</button>
|
||||
)}
|
||||
<small className="sql-credential-note">
|
||||
Credentials remain in this page only and are sent to the local API per request.
|
||||
</small>
|
||||
{connectionError && <div className="form-error">{connectionError}</div>}
|
||||
</form>
|
||||
|
||||
{!connected ? (
|
||||
<div className="empty compact sql-connect-empty">
|
||||
<h3>Connect to browse SQL data</h3>
|
||||
<p>Use the administrator login configured for this local engine.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sql-explorer">
|
||||
<div className="sql-column">
|
||||
<SqlPanelHeader
|
||||
icon={Database}
|
||||
eyebrow="Databases"
|
||||
title={resource.name}
|
||||
detail={`${databases.length} databases`}
|
||||
action={
|
||||
<button
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
title="Refresh databases"
|
||||
disabled={databasesMut.isPending}
|
||||
onClick={loadDatabases}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="sql-list">
|
||||
{databases.length === 0 && <div className="muted padded">No databases</div>}
|
||||
{databases.length > 0 && !databaseExists && <div className="form-error">Database not found: {selectedDatabase}</div>}
|
||||
{databases.map((database) => (
|
||||
<button
|
||||
key={database.name}
|
||||
className={`sql-list-row ${selectedDatabase === database.name ? "selected" : ""}`}
|
||||
type="button"
|
||||
onClick={() => selectDatabase(database)}
|
||||
>
|
||||
<span>
|
||||
<strong>{database.name}</strong>
|
||||
<small>{database.state}</small>
|
||||
</span>
|
||||
{database.isSystem && <em>System</em>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sql-column">
|
||||
<SqlPanelHeader
|
||||
icon={Table2}
|
||||
eyebrow="Tables and views"
|
||||
title={selectedDatabase}
|
||||
detail={`${tables.length} objects`}
|
||||
action={
|
||||
<button
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
title="Refresh tables"
|
||||
disabled={!databaseExists || tablesQuery.isFetching}
|
||||
onClick={() => void tablesQuery.refetch()}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{tablesQuery.error instanceof Error && (
|
||||
<div className="form-error">{tablesQuery.error.message}</div>
|
||||
)}
|
||||
<div className="sql-list">
|
||||
{tablesQuery.isLoading && <div className="muted padded">Loading objects</div>}
|
||||
{databaseExists && tablesQuery.isSuccess && tables.length === 0 && (
|
||||
<div className="muted padded">No user tables or views</div>
|
||||
)}
|
||||
{databaseExists && tablesQuery.isSuccess && tableName && !selectedTable && <div className="form-error">Table not found: {tableSchema}.{tableName}</div>}
|
||||
{tables.map((table) => (
|
||||
<button
|
||||
key={`${table.schema}.${table.name}`}
|
||||
className={`sql-list-row ${selectedTable?.schema === table.schema && selectedTable.name === table.name ? "selected" : ""}`}
|
||||
type="button"
|
||||
onClick={() => previewTable(table)}
|
||||
>
|
||||
<span>
|
||||
<strong>{table.schema}.{table.name}</strong>
|
||||
<small>{table.type}</small>
|
||||
</span>
|
||||
<em>{table.rowCount === null ? "—" : `${table.rowCount} rows`}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sql-column sql-workspace">
|
||||
<SqlPanelHeader
|
||||
icon={Code2}
|
||||
eyebrow="Query editor"
|
||||
title={selectedDatabase}
|
||||
detail={engine === "postgresql"
|
||||
? "PostgreSQL SQL against selected database"
|
||||
: "T-SQL against selected database"}
|
||||
/>
|
||||
<div className="sql-query-editor">
|
||||
<textarea
|
||||
className="textarea code-textarea small"
|
||||
value={queryText}
|
||||
onChange={(event) => setQueryText(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={!databaseExists || queryMut.isPending || !queryText.trim()}
|
||||
onClick={runQuery}
|
||||
>
|
||||
<Play size={14} />
|
||||
{queryMut.isPending ? "Running" : "Run query"}
|
||||
</button>
|
||||
</div>
|
||||
{queryMut.error instanceof Error && (
|
||||
<div className="form-error">{queryMut.error.message}</div>
|
||||
)}
|
||||
<SqlQueryResults result={queryMut.data} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SqlQueryResults({ result }: { result?: SqlQueryResult }) {
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="empty compact sql-results-empty">
|
||||
<h3>No query results</h3>
|
||||
<p>Select a table for a 100-row preview or run SQL.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const affected = result.rowsAffected.reduce((total, count) => total + count, 0);
|
||||
|
||||
return (
|
||||
<div className="sql-results">
|
||||
<div className="sql-results-summary">
|
||||
<span>{result.resultSets.length} result sets</span>
|
||||
<span>{affected} rows affected</span>
|
||||
<span>{result.durationMs} ms</span>
|
||||
</div>
|
||||
{result.resultSets.length === 0 && (
|
||||
<div className="muted padded">Command completed without a result set.</div>
|
||||
)}
|
||||
{result.resultSets.map((resultSet, resultSetIndex) => (
|
||||
<div className="sql-result-set" key={resultSetIndex}>
|
||||
<div className="sql-result-set-label">
|
||||
Result {resultSetIndex + 1} · {resultSet.rows.length} rows
|
||||
{resultSet.truncated && " · first 500 shown"}
|
||||
</div>
|
||||
<div className="sql-result-table-wrap">
|
||||
<table className="table sql-result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{resultSet.columns.map((column) => (
|
||||
<th key={column.name} title={column.type}>
|
||||
{column.name}
|
||||
<small>{column.type}</small>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resultSet.rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{resultSet.columns.map((column) => {
|
||||
const value = formatSqlValue(row[column.name]);
|
||||
return (
|
||||
<td key={column.name} title={value}>
|
||||
{value}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SqlPanelHeader({
|
||||
icon: Icon,
|
||||
eyebrow,
|
||||
title,
|
||||
detail,
|
||||
action,
|
||||
}: {
|
||||
icon: typeof Database;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="sql-panel-header">
|
||||
<Icon size={15} />
|
||||
<span>
|
||||
<small>{eyebrow}</small>
|
||||
<strong>{title}</strong>
|
||||
<em>{detail}</em>
|
||||
</span>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function metadataString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function tablePreviewQuery(table: SqlTable, engine: SqlEngine): string {
|
||||
const tableName = `${quoteIdentifier(table.schema, engine)}.${quoteIdentifier(table.name, engine)}`;
|
||||
return engine === 'postgresql'
|
||||
? `SELECT * FROM ${tableName} LIMIT 100;`
|
||||
: `SELECT TOP (100) * FROM ${tableName};`;
|
||||
}
|
||||
|
||||
function quoteIdentifier(value: string, engine: SqlEngine): string {
|
||||
return engine === "postgresql"
|
||||
? `"${value.replace(/"/g, '""')}"`
|
||||
: `[${value.replace(/]/g, "]]")}]`;
|
||||
}
|
||||
|
||||
function formatSqlValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "NULL";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type {CloudDescriptor, CloudProvider} from '@/types/cloud'
|
||||
|
||||
interface CloudSelectorProps {
|
||||
clouds: CloudDescriptor[]
|
||||
selected: CloudProvider
|
||||
onSelect: (cloud: CloudProvider) => void
|
||||
}
|
||||
|
||||
export function CloudSelector({clouds, selected, onSelect}: CloudSelectorProps) {
|
||||
return (
|
||||
<div className="cloud-selector" role="group" aria-label="Cloud provider">
|
||||
{clouds.map((cloud) => (
|
||||
<button
|
||||
key={cloud.id}
|
||||
className={`selector-pill ${selected === cloud.id ? 'active' : ''} ${cloud.availability === 'coming_soon' ? 'soon' : ''}`}
|
||||
onClick={() => onSelect(cloud.id)}
|
||||
type="button"
|
||||
>
|
||||
<span>{cloud.displayName}</span>
|
||||
{cloud.availability === 'coming_soon' && <span className="badge neutral">Soon</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
||||
import {FormEvent, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {Code2, Database, Play, Plus, RefreshCw, Table2, Trash2, type LucideIcon} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {JsonRecordModal} from '@/components/JsonRecordModal'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {
|
||||
createCosmosContainer,
|
||||
deleteCosmosContainer,
|
||||
deleteCosmosItem,
|
||||
listCosmosContainers,
|
||||
listCosmosItems,
|
||||
queryCosmosItems,
|
||||
upsertCosmosItem,
|
||||
} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource, CosmosItem} from '@/types/resource'
|
||||
|
||||
interface CosmosNoSqlPanelProps {
|
||||
cloud: CloudProvider
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
selectedContainerId?: string
|
||||
onSelectContainer: (id: string | undefined) => void
|
||||
}
|
||||
|
||||
export function CosmosNoSqlPanel({cloud, resource, runtimeReachable, selectedContainerId, onSelectContainer}: CosmosNoSqlPanelProps) {
|
||||
const qc = useQueryClient()
|
||||
const accountId = useAccountId()
|
||||
const selectionChanged = useRef(false)
|
||||
const databaseId = resource?.id
|
||||
const [containerName, setContainerName] = useState('')
|
||||
const [partitionKeyPath, setPartitionKeyPath] = useState('/id')
|
||||
const [documentText, setDocumentText] = useState('{\n "id": ""\n}')
|
||||
const [documentError, setDocumentError] = useState<string | null>(null)
|
||||
const [selectedItem, setSelectedItem] = useState<CosmosItem | undefined>()
|
||||
const [addRecordOpen, setAddRecordOpen] = useState(false)
|
||||
const [sql, setSql] = useState('SELECT * FROM c')
|
||||
const [confirmContainer, setConfirmContainer] = useState<string | null>(null)
|
||||
const [confirmItem, setConfirmItem] = useState<string | null>(null)
|
||||
|
||||
const containersKey = useMemo(() => ['cosmos-containers', accountId, cloud, databaseId], [accountId, cloud, databaseId])
|
||||
const itemsKey = useMemo(() => ['cosmos-items', accountId, cloud, databaseId, selectedContainerId], [accountId, cloud, databaseId, selectedContainerId])
|
||||
|
||||
const containersQuery = useQuery({
|
||||
queryKey: containersKey,
|
||||
queryFn: ({signal}) => listCosmosContainers(cloud, databaseId ?? '', signal),
|
||||
enabled: Boolean(databaseId) && runtimeReachable,
|
||||
})
|
||||
|
||||
const containers = containersQuery.data ?? []
|
||||
const selectedContainer = containers.find((container) => container.id === selectedContainerId)
|
||||
|
||||
const itemsQuery = useQuery({
|
||||
queryKey: itemsKey,
|
||||
queryFn: ({signal}) => listCosmosItems(cloud, databaseId ?? '', selectedContainerId ?? '', signal),
|
||||
enabled: Boolean(databaseId && selectedContainer) && runtimeReachable,
|
||||
})
|
||||
|
||||
const createContainerMut = useMutation({
|
||||
mutationFn: () => createCosmosContainer(cloud, databaseId ?? '', {containerName, partitionKeyPath}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({queryKey: containersKey})
|
||||
},
|
||||
})
|
||||
|
||||
const deleteContainerMut = useMutation({
|
||||
mutationFn: (containerId: string) => deleteCosmosContainer(cloud, databaseId ?? '', containerId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({queryKey: containersKey})
|
||||
},
|
||||
})
|
||||
|
||||
const upsertItemMut = useMutation({
|
||||
mutationFn: (document: Record<string, unknown>) => upsertCosmosItem(cloud, databaseId ?? '', selectedContainerId ?? '', document),
|
||||
onSuccess: (item) => {
|
||||
setAddRecordOpen(false)
|
||||
setSelectedItem(item)
|
||||
setDocumentError(null)
|
||||
setDocumentText(JSON.stringify(item.document, null, 2))
|
||||
void qc.invalidateQueries({queryKey: itemsKey})
|
||||
},
|
||||
})
|
||||
|
||||
const deleteItemMut = useMutation({
|
||||
mutationFn: (item: CosmosItem) => deleteCosmosItem(cloud, databaseId ?? '', selectedContainerId ?? '', item.id, item.partitionKey),
|
||||
onSuccess: (_, item) => {
|
||||
if (selectedItem?.id === item.id) resetDocumentEditor()
|
||||
setConfirmItem(null)
|
||||
void qc.invalidateQueries({queryKey: itemsKey})
|
||||
},
|
||||
})
|
||||
|
||||
const queryMut = useMutation({
|
||||
mutationFn: (input: {containerId: string; query: string}) => queryCosmosItems(cloud, databaseId ?? '', input.containerId, input.query),
|
||||
})
|
||||
const saveError = documentError ?? (upsertItemMut.error instanceof Error ? upsertItemMut.error.message : null)
|
||||
const activeQueryResult = queryMut.variables?.containerId === selectedContainerId ? queryMut.data : undefined
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedItem(undefined)
|
||||
setAddRecordOpen(false)
|
||||
setDocumentText('{\n "id": ""\n}')
|
||||
setDocumentError(null)
|
||||
setSql('SELECT * FROM c')
|
||||
}, [cloud, databaseId])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedItem(undefined)
|
||||
setAddRecordOpen(false)
|
||||
setDocumentText('{\n "id": ""\n}')
|
||||
setDocumentError(null)
|
||||
}, [selectedContainerId])
|
||||
|
||||
if (cloud !== 'azure') return null
|
||||
|
||||
if (!databaseId) {
|
||||
return (
|
||||
<section className="cosmos-panel">
|
||||
<div className="empty compact">
|
||||
<h3>Select a Cosmos database</h3>
|
||||
<p>Containers, documents, and SQL queries are loaded after a database is selected.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function submitContainer(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!containerName.trim()) return
|
||||
createContainerMut.mutate(undefined, {
|
||||
onSuccess: (container) => {
|
||||
if (selectionChanged.current) return
|
||||
onSelectContainer(container.id)
|
||||
setContainerName('')
|
||||
setPartitionKeyPath('/id')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function submitDocument(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
try {
|
||||
const parsed = JSON.parse(documentText) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
setDocumentError('Document must be a JSON object.')
|
||||
return
|
||||
}
|
||||
if (!('id' in parsed) || !String((parsed as Record<string, unknown>).id ?? '').trim()) {
|
||||
setDocumentError('Document id is required.')
|
||||
return
|
||||
}
|
||||
setDocumentError(null)
|
||||
upsertItemMut.mutate(parsed as Record<string, unknown>)
|
||||
} catch (error) {
|
||||
setDocumentError(error instanceof Error ? error.message : 'Invalid JSON document.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="cosmos-panel">
|
||||
<div className="cosmos-column">
|
||||
<PanelHeader icon={Database} eyebrow="Containers" title={databaseId} detail={`${containers.length} containers`}/>
|
||||
<form className="cosmos-inline-form" onSubmit={submitContainer}>
|
||||
<input className="input" value={containerName} onChange={(event) => setContainerName(event.target.value)} placeholder="Container name" disabled={!runtimeReachable}/>
|
||||
<input className="input" value={partitionKeyPath} onChange={(event) => setPartitionKeyPath(event.target.value)} placeholder="/id" disabled={!runtimeReachable}/>
|
||||
<button className="button" type="submit" disabled={!runtimeReachable || createContainerMut.isPending || !containerName.trim()}>
|
||||
<Plus size={14}/>
|
||||
{createContainerMut.isPending ? 'Creating' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
{createContainerMut.error instanceof Error && <div className="form-error">{createContainerMut.error.message}</div>}
|
||||
{containersQuery.error instanceof Error && <div className="form-error">{containersQuery.error.message}</div>}
|
||||
<div className="cosmos-list">
|
||||
{containersQuery.isLoading && <div className="muted padded">Loading containers</div>}
|
||||
{containersQuery.isSuccess && containers.length === 0 && <div className="muted padded">No containers</div>}
|
||||
{containersQuery.isSuccess && selectedContainerId && !selectedContainer && <div className="form-error">Container not found: {selectedContainerId}</div>}
|
||||
{containers.map((container) => (
|
||||
<button
|
||||
key={container.id}
|
||||
className={`cosmos-list-row ${selectedContainerId === container.id ? 'selected' : ''}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (container.id === selectedContainerId) return
|
||||
selectionChanged.current = true
|
||||
onSelectContainer(container.id)
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{container.name}</strong>
|
||||
<small>Partition key {container.partitionKeyPath}</small>
|
||||
</span>
|
||||
{confirmContainer === container.id ? (
|
||||
<em
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
deleteContainerMut.mutate(container.id, {
|
||||
onSuccess: () => {
|
||||
if (selectionChanged.current) return
|
||||
if (selectedContainerId === container.id) onSelectContainer(undefined)
|
||||
setConfirmContainer(null)
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</em>
|
||||
) : (
|
||||
<Trash2
|
||||
size={13}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setConfirmContainer(container.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cosmos-column cosmos-column--wide">
|
||||
<PanelHeader icon={Table2} eyebrow="Documents" title={selectedContainer?.name ?? selectedContainerId ?? 'Select container'} detail={`${itemsQuery.data?.length ?? 0} items`}/>
|
||||
<div className="cosmos-toolbar">
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={!selectedContainer || !runtimeReachable}
|
||||
onClick={() => {
|
||||
upsertItemMut.reset()
|
||||
setAddRecordOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={14}/>
|
||||
Add record
|
||||
</button>
|
||||
<button className="button" type="button" disabled={!selectedContainer || !runtimeReachable || itemsQuery.isFetching} onClick={() => itemsQuery.refetch()}>
|
||||
<RefreshCw size={14}/>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{itemsQuery.error instanceof Error && <div className="form-error">{itemsQuery.error.message}</div>}
|
||||
{deleteItemMut.error instanceof Error && <div className="form-error">{deleteItemMut.error.message}</div>}
|
||||
<div className="cosmos-items-table">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Updated</th>
|
||||
<th>ETag</th>
|
||||
<th aria-label="Actions"/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(itemsQuery.data ?? []).map((item) => (
|
||||
<tr key={item.id} className={selectedItem?.id === item.id ? 'selected' : ''}>
|
||||
<td onClick={() => selectItem(item)}>{item.id}</td>
|
||||
<td onClick={() => selectItem(item)}>{formatValue(item.timestamp)}</td>
|
||||
<td onClick={() => selectItem(item)}><code>{formatShort(item.etag)}</code></td>
|
||||
<td className="table-actions">
|
||||
{confirmItem === item.id ? (
|
||||
<button className="button danger compact" type="button" onClick={() => deleteItemMut.mutate(item)}>Confirm</button>
|
||||
) : (
|
||||
<button className="icon-btn danger" type="button" title={`Delete ${item.id}`} onClick={() => setConfirmItem(item.id)}>
|
||||
<Trash2 size={13}/>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!selectedContainerId && <div className="empty compact"><h3>Select a container</h3><p>Documents are scoped to a Cosmos container.</p></div>}
|
||||
{itemsQuery.isLoading && <div className="muted padded">Loading documents</div>}
|
||||
{selectedContainerId && itemsQuery.isSuccess && itemsQuery.data.length === 0 && <div className="empty compact"><h3>No documents</h3><p>Create a JSON document or run a query after data exists.</p></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cosmos-column">
|
||||
<PanelHeader icon={Code2} eyebrow="Document editor" title={selectedItem ? `Update ${selectedItem.id}` : 'Select document'} detail={selectedItem ? 'Edits replace the selected document' : 'Choose a document to inspect or update'}/>
|
||||
{selectedItem ? (
|
||||
<form className="cosmos-editor" onSubmit={submitDocument}>
|
||||
<textarea className="textarea code-textarea" value={documentText} onChange={(event) => setDocumentText(event.target.value)} spellCheck={false}/>
|
||||
<button className="button primary" type="submit" disabled={!selectedContainer || !runtimeReachable || upsertItemMut.isPending}>
|
||||
{upsertItemMut.isPending ? 'Saving' : 'Update document'}
|
||||
</button>
|
||||
{saveError && <div className="form-error">{saveError}</div>}
|
||||
</form>
|
||||
) : (
|
||||
<div className="empty compact"><h3>Select a document</h3><p>Its JSON contents appear here.</p></div>
|
||||
)}
|
||||
|
||||
<PanelHeader icon={Play} eyebrow="SQL query" title="Cosmos SQL" detail="Runs against selected container"/>
|
||||
<div className="cosmos-query">
|
||||
<textarea className="textarea code-textarea small" value={sql} onChange={(event) => setSql(event.target.value)} spellCheck={false}/>
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
disabled={!selectedContainer || !runtimeReachable || queryMut.isPending}
|
||||
onClick={() => selectedContainerId && queryMut.mutate({containerId: selectedContainerId, query: sql})}
|
||||
>
|
||||
<Play size={14}/>
|
||||
{queryMut.isPending ? 'Running' : 'Run query'}
|
||||
</button>
|
||||
{queryMut.error instanceof Error && <div className="form-error">{queryMut.error.message}</div>}
|
||||
{activeQueryResult && (
|
||||
<>
|
||||
<div className="muted">{activeQueryResult.count} query results</div>
|
||||
<pre className="cosmos-query-result">{JSON.stringify(activeQueryResult.items, null, 2)}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JsonRecordModal
|
||||
open={addRecordOpen}
|
||||
title={`Add record to ${selectedContainer?.name ?? 'container'}`}
|
||||
description={cosmosModalDescription(selectedContainer?.partitionKeyPath)}
|
||||
initialValue={JSON.stringify(cosmosRecordTemplate(selectedContainer?.partitionKeyPath), null, 2)}
|
||||
isPending={upsertItemMut.isPending}
|
||||
submitError={upsertItemMut.error instanceof Error ? upsertItemMut.error.message : undefined}
|
||||
onClose={() => setAddRecordOpen(false)}
|
||||
onSubmit={(document) => upsertItemMut.mutate(document)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
|
||||
function selectItem(item: CosmosItem) {
|
||||
setSelectedItem(item)
|
||||
setDocumentText(JSON.stringify(item.document, null, 2))
|
||||
setDocumentError(null)
|
||||
}
|
||||
|
||||
function resetDocumentEditor() {
|
||||
setSelectedItem(undefined)
|
||||
setDocumentText('{\n "id": ""\n}')
|
||||
setDocumentError(null)
|
||||
setConfirmItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
function cosmosRecordTemplate(partitionKeyPath = '/id'): Record<string, unknown> {
|
||||
const document: Record<string, unknown> = {id: ''}
|
||||
const segments = partitionKeyPath.replace(/^\//, '').split('/').filter(Boolean)
|
||||
if (segments.length === 0 || segments.length === 1 && segments[0] === 'id') return document
|
||||
|
||||
let target = document
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const child: Record<string, unknown> = {}
|
||||
target[segment] = child
|
||||
target = child
|
||||
}
|
||||
target[segments[segments.length - 1] ?? 'id'] = ''
|
||||
return document
|
||||
}
|
||||
|
||||
function cosmosModalDescription(partitionKeyPath = '/id'): string {
|
||||
const required = partitionKeyPath === '/id' ? 'Include id.' : `Include id and partition key ${partitionKeyPath}.`
|
||||
return `${required} A matching id and partition key replaces the existing record.`
|
||||
}
|
||||
|
||||
function PanelHeader({icon, eyebrow, title, detail}: {icon: LucideIcon; eyebrow: string; title: string; detail: string}) {
|
||||
const Icon = icon
|
||||
return (
|
||||
<div className="cosmos-panel-header">
|
||||
<Icon size={15}/>
|
||||
<span>
|
||||
<small>{eyebrow}</small>
|
||||
<strong>{title}</strong>
|
||||
<em>{detail}</em>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatValue(value: string | null): string {
|
||||
return value || '-'
|
||||
}
|
||||
|
||||
function formatShort(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
return value.length > 16 ? `${value.slice(0, 16)}...` : value
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import {
|
||||
createCloudResource,
|
||||
listDatabaseOrderableClasses,
|
||||
} from "@/api/cloudProxyClient";
|
||||
import {
|
||||
listEc2SecurityGroups,
|
||||
type Ec2SecurityGroup,
|
||||
} from "@/api/aws/ec2.api";
|
||||
import type { CloudProvider } from "@/types/cloud";
|
||||
import type { CloudResource } from "@/types/resource";
|
||||
|
||||
const IDENTIFIER_PATTERN = /^(?!.*--)[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
interface CreateRdsInstanceFormProps {
|
||||
cloud: CloudProvider;
|
||||
onSuccess: (resource: CloudResource) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CreateRdsInstanceForm({
|
||||
cloud,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: CreateRdsInstanceFormProps) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Instance configuration
|
||||
const [dbInstanceIdentifier, setDbInstanceIdentifier] = useState("");
|
||||
const [engine, setEngine] = useState("postgres");
|
||||
const [dbInstanceClass, setDbInstanceClass] = useState("");
|
||||
const [masterUserPassword, setMasterUserPassword] = useState("");
|
||||
|
||||
// Networking settings
|
||||
const [selectedSgs, setSelectedSgs] = useState<string[]>([]);
|
||||
|
||||
// Additional settings
|
||||
const [masterUsername, setMasterUsername] = useState("root");
|
||||
const [allocatedStorage, setAllocatedStorage] = useState("20");
|
||||
const [dbName, setDbName] = useState("");
|
||||
const [engineVersion, setEngineVersion] = useState("");
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
// Fetch available instance classes from AWS SDK via backend
|
||||
const classesQuery = useQuery({
|
||||
queryKey: ["cloud-database-orderable-classes", cloud, engine],
|
||||
queryFn: ({ signal }) => listDatabaseOrderableClasses(cloud, engine, signal),
|
||||
enabled: Boolean(engine),
|
||||
});
|
||||
|
||||
const availableClasses = classesQuery.data ?? [];
|
||||
const selectedClass =
|
||||
dbInstanceClass && availableClasses.includes(dbInstanceClass)
|
||||
? dbInstanceClass
|
||||
: (availableClasses[0] ?? "");
|
||||
|
||||
// Fetch available security groups (AWS)
|
||||
const sgsQuery = useQuery({
|
||||
queryKey: ["ec2", "security-groups"],
|
||||
queryFn: ({ signal }) => listEc2SecurityGroups(undefined, signal),
|
||||
enabled: cloud === "aws",
|
||||
});
|
||||
|
||||
function toggleSg(groupId: string, checked: boolean) {
|
||||
setSelectedSgs((prev) =>
|
||||
checked ? [...prev, groupId] : prev.filter((id) => id !== groupId),
|
||||
);
|
||||
}
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: Record<string, unknown>) =>
|
||||
createCloudResource(cloud, "database", values),
|
||||
onSuccess: (created) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["cloud-resources", cloud, "database"],
|
||||
});
|
||||
onSuccess(created);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setValidationError(null);
|
||||
|
||||
const trimmedIdentifier = dbInstanceIdentifier.trim();
|
||||
if (!trimmedIdentifier) {
|
||||
setValidationError("DB Instance Identifier is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IDENTIFIER_PATTERN.test(trimmedIdentifier)) {
|
||||
setValidationError(
|
||||
"DB Instance Identifier must start with a lowercase letter, contain only lowercase letters, digits, and hyphens, and cannot end with a hyphen or contain consecutive hyphens.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedClass) {
|
||||
setValidationError("DB Instance Class is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (masterUserPassword.length < 8) {
|
||||
setValidationError("Master User Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
|
||||
createMut.mutate({
|
||||
dbInstanceIdentifier: trimmedIdentifier,
|
||||
engine,
|
||||
dbInstanceClass: selectedClass,
|
||||
masterUserPassword,
|
||||
masterUsername: masterUsername.trim() || "root",
|
||||
allocatedStorage: allocatedStorage.trim() || "20",
|
||||
dbName: dbName.trim() || undefined,
|
||||
engineVersion: engineVersion.trim() || undefined,
|
||||
securityGroupIds: selectedSgs.length ? selectedSgs : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
validationError ??
|
||||
(createMut.error instanceof Error ? createMut.error.message : null);
|
||||
|
||||
return (
|
||||
<form className="launch-form" onSubmit={handleSubmit} noValidate={false}>
|
||||
{/* ── Required Instance Settings ─────────────────────────────── */}
|
||||
<div className="launch-form-group">Instance Configuration</div>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>
|
||||
DB Instance Identifier <em className="field-required">*</em>
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
value={dbInstanceIdentifier}
|
||||
onChange={(e) => setDbInstanceIdentifier(e.target.value)}
|
||||
placeholder="e.g. orders-database"
|
||||
required
|
||||
maxLength={63}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>
|
||||
Engine <em className="field-required">*</em>
|
||||
</span>
|
||||
<select
|
||||
className="input"
|
||||
value={engine}
|
||||
onChange={(e) => {
|
||||
const nextEngine = e.target.value;
|
||||
setEngine(nextEngine);
|
||||
setDbInstanceClass("");
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="mariadb">MariaDB</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>
|
||||
DB Instance Class <em className="field-required">*</em>
|
||||
{classesQuery.isFetching && (
|
||||
<Loader2
|
||||
size={11}
|
||||
className="spin"
|
||||
style={{ marginLeft: 6, display: "inline-block" }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedClass}
|
||||
onChange={(e) => setDbInstanceClass(e.target.value)}
|
||||
disabled={classesQuery.isLoading || availableClasses.length === 0}
|
||||
required
|
||||
>
|
||||
{classesQuery.isLoading && (
|
||||
<option value="" disabled>
|
||||
Loading available instance classes…
|
||||
</option>
|
||||
)}
|
||||
{classesQuery.isError && (
|
||||
<option value="" disabled>
|
||||
Failed to load instance classes
|
||||
</option>
|
||||
)}
|
||||
{!classesQuery.isLoading &&
|
||||
!classesQuery.isError &&
|
||||
availableClasses.length === 0 && (
|
||||
<option value="" disabled>
|
||||
No instance classes available
|
||||
</option>
|
||||
)}
|
||||
{availableClasses.map((cls) => (
|
||||
<option key={cls} value={cls}>
|
||||
{cls}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>
|
||||
Master User Password <em className="field-required">*</em>
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={masterUserPassword}
|
||||
onChange={(e) => setMasterUserPassword(e.target.value)}
|
||||
placeholder="Min 8 characters"
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* ── Networking — optional ─────────────────────────────────── */}
|
||||
{cloud === "aws" && (
|
||||
<>
|
||||
<div className="launch-form-group">Networking — optional</div>
|
||||
<div className="launch-field launch-field--span">
|
||||
<span style={{ fontSize: "12px", color: "var(--text-2)" }}>
|
||||
Security Groups
|
||||
</span>
|
||||
<div className="sg-checklist" style={{ marginTop: "4px" }}>
|
||||
{sgsQuery.isLoading && <span className="muted">Loading…</span>}
|
||||
{!sgsQuery.isLoading && (sgsQuery.data ?? []).length === 0 && (
|
||||
<span className="muted">No security groups found.</span>
|
||||
)}
|
||||
{(sgsQuery.data ?? []).map((sg: Ec2SecurityGroup) => (
|
||||
<label key={sg.groupId} className="sg-check-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSgs.includes(sg.groupId)}
|
||||
onChange={(e) => toggleSg(sg.groupId, e.target.checked)}
|
||||
/>
|
||||
<span>{sg.groupName || sg.groupId}</span>
|
||||
{sg.groupName && <code>{sg.groupId}</code>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Additional Database Settings ───────────────────────────── */}
|
||||
<div className="launch-form-group">Database Settings — optional</div>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>Master Username</span>
|
||||
<input
|
||||
className="input"
|
||||
value={masterUsername}
|
||||
onChange={(e) => setMasterUsername(e.target.value)}
|
||||
placeholder="root"
|
||||
maxLength={63}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>Allocated Storage (GiB)</span>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
value={allocatedStorage}
|
||||
onChange={(e) => setAllocatedStorage(e.target.value)}
|
||||
placeholder="20"
|
||||
min={5}
|
||||
max={65536}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>Database Name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={dbName}
|
||||
onChange={(e) => setDbName(e.target.value)}
|
||||
placeholder="e.g. myapp"
|
||||
maxLength={64}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="launch-field">
|
||||
<span>Engine Version</span>
|
||||
<input
|
||||
className="input"
|
||||
value={engineVersion}
|
||||
onChange={(e) => setEngineVersion(e.target.value)}
|
||||
placeholder="e.g. 16.3"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* ── Actions ────────────────────────────────────────────────── */}
|
||||
<div className="launch-form-actions">
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={createMut.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="button primary"
|
||||
type="submit"
|
||||
disabled={createMut.isPending}
|
||||
>
|
||||
{createMut.isPending ? (
|
||||
<Loader2 className="spin" size={13} />
|
||||
) : (
|
||||
<Plus size={13} />
|
||||
)}
|
||||
{createMut.isPending ? "Creating…" : "Create DB Instance"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div
|
||||
className="field-error launch-field--span"
|
||||
role="alert"
|
||||
style={{ marginTop: "4px" }}
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState } from "react";
|
||||
import { Camera, Loader2, Plus, RefreshCw } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createDatabaseSnapshot, listDatabaseSnapshots } from "@/api/cloudProxyClient";
|
||||
import { HttpError } from "@/api/HttpClient";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { capabilityEnabled } from "@/lib/capabilities";
|
||||
import type { CloudProvider } from "@/types/cloud";
|
||||
import type { CloudResource } from "@/types/resource";
|
||||
import type { CapabilitySchema, DatabaseActionName } from "@/types/schema";
|
||||
|
||||
export interface DatabaseSnapshotsPanelProps {
|
||||
cloud: CloudProvider;
|
||||
instances: CloudResource[];
|
||||
listCapability?: CapabilitySchema<DatabaseActionName>;
|
||||
createCapability?: CapabilitySchema<DatabaseActionName>;
|
||||
runtimeReachable: boolean;
|
||||
}
|
||||
|
||||
export function DatabaseSnapshotsPanel({
|
||||
cloud,
|
||||
instances,
|
||||
listCapability,
|
||||
createCapability,
|
||||
runtimeReachable,
|
||||
}: DatabaseSnapshotsPanelProps) {
|
||||
const qc = useQueryClient();
|
||||
const [sourceInstance, setSourceInstance] = useState("");
|
||||
const [snapshotId, setSnapshotId] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const canList = runtimeReachable && capabilityEnabled(listCapability);
|
||||
const canCreate = runtimeReachable && capabilityEnabled(createCapability);
|
||||
|
||||
const snapshotsQuery = useQuery({
|
||||
queryKey: ["database-snapshots", cloud],
|
||||
queryFn: ({ signal }) => listDatabaseSnapshots(cloud, undefined, signal),
|
||||
enabled: canList,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (input: { instanceIdentifier: string; snapshotIdentifier: string }) =>
|
||||
createDatabaseSnapshot(cloud, input),
|
||||
onSuccess: () => {
|
||||
setSourceInstance("");
|
||||
setSnapshotId("");
|
||||
setCreateOpen(false);
|
||||
void qc.invalidateQueries({ queryKey: ["database-snapshots", cloud] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSourceChange = (newInstance: string) => {
|
||||
setSourceInstance(newInstance);
|
||||
if (newInstance) {
|
||||
setSnapshotId(`${newInstance}-snapshot-${Date.now()}`);
|
||||
} else {
|
||||
setSnapshotId("");
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = Boolean(sourceInstance.trim() && snapshotId.trim());
|
||||
|
||||
const handleCreateSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isFormValid || !canCreate || createMut.isPending) return;
|
||||
createMut.mutate({
|
||||
instanceIdentifier: sourceInstance.trim(),
|
||||
snapshotIdentifier: snapshotId.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
if (
|
||||
error instanceof HttpError &&
|
||||
typeof error.body === "object" &&
|
||||
error.body !== null &&
|
||||
"code" in error.body &&
|
||||
(error.body as { code?: unknown }).code === "operation_not_implemented"
|
||||
) {
|
||||
return "Snapshot creation is available in Floci UI, but the selected Floci runtime does not implement CreateDBSnapshot.";
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "Snapshot creation failed.";
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="table-panel">
|
||||
<div className="input-row resource-table-bar">
|
||||
<div>
|
||||
<p className="eyebrow">Database Snapshots</p>
|
||||
<span className="muted">
|
||||
{snapshotsQuery.data ? `${snapshotsQuery.data.length} snapshots` : "Snapshots"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="resource-table-tools">
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
disabled={!canCreate}
|
||||
title={createCapability?.reason}
|
||||
onClick={() => setCreateOpen((open) => !open)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Create DB snapshot
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
disabled={!canList || snapshotsQuery.isFetching}
|
||||
onClick={() => snapshotsQuery.refetch()}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
{snapshotsQuery.isFetching ? "Loading" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createOpen && (
|
||||
<div className="resource-create-inline" style={{ margin: "10px 12px 16px" }}>
|
||||
<form onSubmit={handleCreateSubmit} className="dynamic-form">
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="source-instance-select">
|
||||
Source Instance *
|
||||
</label>
|
||||
<select
|
||||
id="source-instance-select"
|
||||
className="input"
|
||||
required
|
||||
value={sourceInstance}
|
||||
onChange={(e) => handleSourceChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select a DB instance</option>
|
||||
{instances.map((inst) => (
|
||||
<option key={inst.id} value={inst.name}>
|
||||
{inst.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="snapshot-id-input">
|
||||
Snapshot Identifier *
|
||||
</label>
|
||||
<input
|
||||
id="snapshot-id-input"
|
||||
className="input"
|
||||
type="text"
|
||||
required
|
||||
value={snapshotId}
|
||||
onChange={(e) => setSnapshotId(e.target.value)}
|
||||
placeholder="e.g. my-db-snapshot-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{createMut.isError && (
|
||||
<div role="alert" className="error-text compact-text" style={{ marginTop: 8 }}>
|
||||
{getErrorMessage(createMut.error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions" style={{ marginTop: 12 }}>
|
||||
<button
|
||||
className="button primary"
|
||||
type="submit"
|
||||
disabled={!canCreate || !isFormValid || createMut.isPending}
|
||||
title={createCapability?.reason}
|
||||
>
|
||||
{createMut.isPending ? "Creating…" : "Create snapshot"}
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
disabled={createMut.isPending}
|
||||
onClick={() => {
|
||||
setCreateOpen(false);
|
||||
createMut.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canList ? (
|
||||
<p className="muted compact-text" style={{ padding: "16px" }}>
|
||||
{listCapability?.reason ?? "Snapshot listing is not available."}
|
||||
</p>
|
||||
) : snapshotsQuery.isLoading ? (
|
||||
<div className="empty compact">
|
||||
<Loader2 size={18} className="spin" />
|
||||
<p>Loading snapshots…</p>
|
||||
</div>
|
||||
) : snapshotsQuery.isError ? (
|
||||
<div role="alert" className="error-text compact-text" style={{ padding: "16px" }}>
|
||||
{snapshotsQuery.error instanceof Error
|
||||
? snapshotsQuery.error.message
|
||||
: "Failed to load database snapshots."}
|
||||
</div>
|
||||
) : (snapshotsQuery.data?.length ?? 0) === 0 ? (
|
||||
<EmptyState
|
||||
icon={Camera}
|
||||
title="No snapshots"
|
||||
description="No snapshots returned for this account."
|
||||
/>
|
||||
) : (
|
||||
<div className="snapshot-list" style={{ padding: "0 12px 12px" }}>
|
||||
{snapshotsQuery.data?.map((snapshot) => (
|
||||
<div className="snapshot-row" key={snapshot.id || snapshot.name}>
|
||||
<div>
|
||||
<strong>{snapshot.name}</strong>
|
||||
<span>
|
||||
{snapshot.instanceIdentifier
|
||||
? `Instance: ${snapshot.instanceIdentifier}`
|
||||
: "Unknown instance"}
|
||||
{snapshot.createdAt
|
||||
? ` · ${snapshot.createdAt}`
|
||||
: " · No creation timestamp"}
|
||||
{snapshot.engine ? ` · ${snapshot.engine}` : ""}
|
||||
{snapshot.version ? ` (${snapshot.version})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<span className="badge neutral">
|
||||
{snapshot.status ?? "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {DynamicFormRenderer} from './DynamicFormRenderer'
|
||||
import type {ServiceSchema} from '@/types/schema'
|
||||
|
||||
const schema: ServiceSchema = {
|
||||
cloud: 'aws',
|
||||
service: 'serverless',
|
||||
displayName: 'AWS Lambda',
|
||||
fields: [
|
||||
{
|
||||
name: 'runtime',
|
||||
label: 'Runtime',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'nodejs22.x',
|
||||
options: [
|
||||
{label: 'Node.js 22.x', value: 'nodejs22.x'},
|
||||
{label: 'Python 3.12', value: 'python3.12'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
label: 'Hello World Source',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
defaultValueWhen: {
|
||||
field: 'runtime',
|
||||
values: {
|
||||
'nodejs22.x': 'exports.handler = async () => {}',
|
||||
'python3.12': 'def handler(event, context):\n return {}',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: ['create'],
|
||||
filters: [],
|
||||
columns: [],
|
||||
}
|
||||
|
||||
test('replaces Hello World source when the Lambda runtime changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DynamicFormRenderer schema={schema} isSubmitting={false} onSubmit={vi.fn()}/>)
|
||||
|
||||
expect(screen.getByLabelText('Hello World Source')).toHaveValue('exports.handler = async () => {}')
|
||||
await user.selectOptions(screen.getByRole('combobox'), 'python3.12')
|
||||
|
||||
expect(screen.getByLabelText('Hello World Source')).toHaveValue('def handler(event, context):\n return {}')
|
||||
})
|
||||
@@ -0,0 +1,263 @@
|
||||
import {FormEvent, useState} from 'react'
|
||||
import {Plus} from 'lucide-react'
|
||||
import type {FieldSchema, ServiceSchema} from '@/types/schema'
|
||||
|
||||
interface DynamicFormRendererProps {
|
||||
schema: ServiceSchema
|
||||
fields?: FieldSchema[]
|
||||
initialValues?: Record<string, unknown>
|
||||
isSubmitting: boolean
|
||||
submitLabel?: string
|
||||
pendingLabel?: string
|
||||
submitError?: string | null
|
||||
onCancel?: () => void
|
||||
onSubmit: (values: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
export function DynamicFormRenderer({
|
||||
schema,
|
||||
fields,
|
||||
initialValues,
|
||||
isSubmitting,
|
||||
submitLabel = 'Create',
|
||||
pendingLabel = 'Creating',
|
||||
submitError,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: DynamicFormRendererProps) {
|
||||
const activeFields = fields ?? schema.fields
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
getInitialFormValues(activeFields, initialValues),
|
||||
)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const nextErrors = validateValues(activeFields, values)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
onSubmit(values)
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="dynamic-form" onSubmit={submit} noValidate>
|
||||
{activeFields.map((field) => (
|
||||
<FieldRow
|
||||
key={field.name}
|
||||
field={field}
|
||||
required={isFieldRequired(field, values)}
|
||||
maxLength={fieldMaxLength(field, values).value}
|
||||
value={values[field.name] ?? ''}
|
||||
error={errors[field.name]}
|
||||
onChange={(value) => {
|
||||
setValues((prev) => withDependentDefaults(activeFields, prev, field.name, value))
|
||||
setErrors((prev) => {
|
||||
const next = {...prev}
|
||||
delete next[field.name]
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div style={{gridColumn: '1 / -1', justifySelf: 'end', display: 'flex', gap: '8px', alignItems: 'center', marginTop: 4}}>
|
||||
{onCancel && (
|
||||
<button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button className="button primary" type="submit" disabled={isSubmitting}>
|
||||
{submitLabel === 'Create' && <Plus size={14}/>}
|
||||
{isSubmitting ? pendingLabel : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
{submitError && <div className="form-error" role="alert">{submitError}</div>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldRow({field, required, maxLength, value, error, onChange}: {field: FieldSchema; required: boolean; maxLength?: number; value: string; error?: string; onChange: (value: string) => void}) {
|
||||
return (
|
||||
<>
|
||||
{field.group && <div className="dynamic-form-group">{field.group}</div>}
|
||||
<label className={`dynamic-field${field.span ? ' dynamic-field--span' : ''}`}>
|
||||
<span>
|
||||
{field.label}
|
||||
{required && <em className="field-required">*</em>}
|
||||
</span>
|
||||
<FieldInput field={field} required={required} maxLength={maxLength} value={value} invalid={Boolean(error)} messageId={`${field.name}-message`} onChange={onChange}/>
|
||||
{(error || field.description) && (
|
||||
<small id={`${field.name}-message`} className={error ? 'field-error' : undefined}>
|
||||
{error ?? field.description}
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldInput({field, required, maxLength, value, invalid, messageId, onChange}: {field: FieldSchema; required: boolean; maxLength?: number; value: string; invalid: boolean; messageId: string; onChange: (value: string) => void}) {
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<select className={`input ${invalid ? 'invalid' : ''}`} value={value} required={required} aria-invalid={invalid || undefined} aria-describedby={invalid || field.description ? messageId : undefined} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="">Default</option>
|
||||
{(field.options ?? []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<textarea
|
||||
className={`textarea code-textarea ${invalid ? 'invalid' : ''}`}
|
||||
value={value}
|
||||
required={required}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid || field.description ? messageId : undefined}
|
||||
minLength={field.validation?.minLength}
|
||||
maxLength={maxLength}
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'file') {
|
||||
return (
|
||||
<input
|
||||
type="file"
|
||||
className={`input file-input ${invalid ? 'invalid' : ''}`}
|
||||
required={required}
|
||||
accept={field.accept}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid || field.description ? messageId : undefined}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) {
|
||||
onChange('')
|
||||
return
|
||||
}
|
||||
void readFileAsBase64(file).then(onChange)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type={field.type === 'password' ? 'password' : 'text'}
|
||||
className={`input ${invalid ? 'invalid' : ''}`}
|
||||
value={value}
|
||||
required={required}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={invalid || field.description ? messageId : undefined}
|
||||
minLength={field.validation?.minLength}
|
||||
maxLength={maxLength}
|
||||
pattern={field.validation?.pattern}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={field.label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function readFileAsBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Unable to read the selected file.'))
|
||||
reader.onload = () => {
|
||||
const result = reader.result
|
||||
if (typeof result !== 'string') {
|
||||
reject(new Error('Unable to read the selected file.'))
|
||||
return
|
||||
}
|
||||
resolve(result.slice(result.indexOf(',') + 1))
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function getInitialFormValues(
|
||||
fields: FieldSchema[],
|
||||
initialValues?: Record<string, unknown>,
|
||||
): Record<string, string> {
|
||||
const defaults = defaultValues(fields)
|
||||
if (initialValues) {
|
||||
for (const [key, val] of Object.entries(initialValues)) {
|
||||
if (val !== undefined && val !== null) {
|
||||
defaults[key] = String(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
function defaultValues(fields: FieldSchema[]): Record<string, string> {
|
||||
const values = Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? '']))
|
||||
for (const field of fields) {
|
||||
const conditional = field.defaultValueWhen
|
||||
if (conditional) {
|
||||
values[field.name] = conditional.values[values[conditional.field] ?? ''] ?? field.defaultValue ?? ''
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function withDependentDefaults(
|
||||
fields: FieldSchema[],
|
||||
currentValues: Record<string, string>,
|
||||
changedField: string,
|
||||
value: string,
|
||||
): Record<string, string> {
|
||||
const nextValues = {...currentValues, [changedField]: value}
|
||||
for (const field of fields) {
|
||||
const conditional = field.defaultValueWhen
|
||||
if (conditional?.field === changedField) {
|
||||
nextValues[field.name] = conditional.values[value] ?? field.defaultValue ?? ''
|
||||
}
|
||||
}
|
||||
return nextValues
|
||||
}
|
||||
|
||||
function validateValues(fields: FieldSchema[], values: Record<string, string>): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
for (const field of fields) {
|
||||
const value = (values[field.name] ?? '').trim()
|
||||
if (isFieldRequired(field, values) && !value) {
|
||||
errors[field.name] = `${field.label} is required.`
|
||||
continue
|
||||
}
|
||||
if (!value) continue
|
||||
if (field.validation?.minLength && value.length < field.validation.minLength) {
|
||||
errors[field.name] = field.validation.message ?? `${field.label} is too short.`
|
||||
continue
|
||||
}
|
||||
const maxLength = fieldMaxLength(field, values)
|
||||
if (maxLength.value && value.length > maxLength.value) {
|
||||
errors[field.name] = maxLength.message ?? `${field.label} is too long.`
|
||||
continue
|
||||
}
|
||||
if (field.validation?.pattern && !new RegExp(field.validation.pattern).test(value)) {
|
||||
errors[field.name] = field.validation.message ?? `${field.label} is invalid.`
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
function fieldMaxLength(field: FieldSchema, values: Record<string, string>): {value?: number; message?: string} {
|
||||
const conditional = field.validation?.maxLengthWhen
|
||||
if (conditional && values[conditional.field] === conditional.equals) {
|
||||
return {value: conditional.value, message: conditional.message}
|
||||
}
|
||||
return {value: field.validation?.maxLength, message: field.validation?.message}
|
||||
}
|
||||
|
||||
function isFieldRequired(field: FieldSchema, values: Record<string, string>): boolean {
|
||||
if (field.required) return true
|
||||
if (!field.requiredWhen) return false
|
||||
return values[field.requiredWhen.field] === field.requiredWhen.equals
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {Braces, Plus, RefreshCw, TableProperties} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {listNoSqlItems, putNoSqlItem} from '@/api/cloudProxyClient'
|
||||
import {JsonRecordModal} from '@/components/JsonRecordModal'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource, NoSqlItem} from '@/types/resource'
|
||||
|
||||
interface DynamoDbTableExplorerProps {
|
||||
cloud: CloudProvider
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
export function DynamoDbTableExplorer({cloud, resource, runtimeReachable}: DynamoDbTableExplorerProps) {
|
||||
const qc = useQueryClient()
|
||||
const accountId = useAccountId()
|
||||
const tableName = resource?.id
|
||||
const [selectedItemId, setSelectedItemId] = useState<string>()
|
||||
const [addRecordOpen, setAddRecordOpen] = useState(false)
|
||||
const itemsKey = useMemo(() => ['nosql-items', accountId, cloud, tableName], [accountId, cloud, tableName])
|
||||
const keyFields = useMemo(() => dynamoKeyFields(resource), [resource])
|
||||
const itemsQuery = useQuery({
|
||||
queryKey: itemsKey,
|
||||
queryFn: ({signal}) => listNoSqlItems(cloud, tableName ?? '', signal),
|
||||
enabled: Boolean(tableName) && runtimeReachable,
|
||||
})
|
||||
const items = itemsQuery.data ?? []
|
||||
const selectedItem = items.find((item) => item.id === selectedItemId)
|
||||
const putItemMut = useMutation({
|
||||
mutationFn: (document: Record<string, unknown>) => putNoSqlItem(cloud, tableName ?? '', document),
|
||||
onSuccess: (item) => {
|
||||
setAddRecordOpen(false)
|
||||
setSelectedItemId(item.id)
|
||||
void qc.invalidateQueries({queryKey: itemsKey})
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedItemId(undefined)
|
||||
setAddRecordOpen(false)
|
||||
}, [tableName])
|
||||
|
||||
if (!tableName) {
|
||||
return (
|
||||
<section className="dynamodb-explorer">
|
||||
<div className="empty compact">
|
||||
<h3>Select a DynamoDB table</h3>
|
||||
<p>Records and their contents load after a table is selected.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="dynamodb-explorer">
|
||||
<div className="cosmos-column cosmos-column--wide">
|
||||
<PanelHeader icon={<TableProperties size={15}/>} eyebrow="Table explorer" title={tableName} detail={`${items.length} records`}/>
|
||||
<div className="cosmos-toolbar">
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={!runtimeReachable}
|
||||
onClick={() => {
|
||||
putItemMut.reset()
|
||||
setAddRecordOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus size={14}/>
|
||||
Add record
|
||||
</button>
|
||||
<button className="button" type="button" disabled={itemsQuery.isFetching} onClick={() => itemsQuery.refetch()}>
|
||||
<RefreshCw size={14}/>
|
||||
{itemsQuery.isFetching ? 'Loading' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{itemsQuery.error instanceof Error && <div className="form-error">{itemsQuery.error.message}</div>}
|
||||
<div className="cosmos-items-table">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Contents</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className={selectedItemId === item.id ? 'selected' : ''}>
|
||||
<td onClick={() => setSelectedItemId(item.id)}><code>{formatJson(item.key)}</code></td>
|
||||
<td onClick={() => setSelectedItemId(item.id)}>{summarize(item)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{itemsQuery.isLoading && <div className="muted padded">Loading records</div>}
|
||||
{itemsQuery.isSuccess && items.length === 0 && <div className="empty compact"><h3>No records</h3><p>This table does not contain any items.</p></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cosmos-column">
|
||||
<PanelHeader icon={<Braces size={15}/>} eyebrow="Record contents" title={selectedItem ? formatJson(selectedItem.key) : 'Select record'} detail="DynamoDB attributes normalized to JSON"/>
|
||||
{selectedItem ? (
|
||||
<pre className="dynamodb-document">{JSON.stringify(selectedItem.document, null, 2)}</pre>
|
||||
) : (
|
||||
<div className="empty compact"><h3>Select a record</h3><p>Full record contents appear here.</p></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<JsonRecordModal
|
||||
open={addRecordOpen}
|
||||
title={`Add record to ${tableName}`}
|
||||
description={dynamoModalDescription(keyFields)}
|
||||
initialValue={JSON.stringify(Object.fromEntries(keyFields.map(({name, type}) => [name, type === 'N' ? '0' : ''])), null, 2)}
|
||||
isPending={putItemMut.isPending}
|
||||
submitError={putItemMut.error instanceof Error ? putItemMut.error.message : undefined}
|
||||
onClose={() => setAddRecordOpen(false)}
|
||||
onSubmit={(document) => putItemMut.mutate(document)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface DynamoKeyField {
|
||||
name: string
|
||||
type?: 'S' | 'N' | 'B'
|
||||
}
|
||||
|
||||
function dynamoKeyFields(resource?: CloudResource): DynamoKeyField[] {
|
||||
const keySchema = Array.isArray(resource?.metadata.keySchema) ? resource.metadata.keySchema : []
|
||||
const definitions = Array.isArray(resource?.metadata.attributeDefinitions) ? resource.metadata.attributeDefinitions : []
|
||||
const types = new Map(definitions.flatMap((definition) => {
|
||||
if (!definition || typeof definition !== 'object') return []
|
||||
const {AttributeName, AttributeType} = definition as {AttributeName?: unknown; AttributeType?: unknown}
|
||||
return typeof AttributeName === 'string' && isDynamoKeyType(AttributeType) ? [[AttributeName, AttributeType] as const] : []
|
||||
}))
|
||||
|
||||
return keySchema.flatMap((key) => {
|
||||
if (!key || typeof key !== 'object') return []
|
||||
const name = (key as {AttributeName?: unknown}).AttributeName
|
||||
return typeof name === 'string' ? [{name, type: types.get(name)}] : []
|
||||
})
|
||||
}
|
||||
|
||||
function dynamoModalDescription(keyFields: DynamoKeyField[]): string {
|
||||
const keys = keyFields.map(({name, type}) => `${name}${type ? ` (${type})` : ''}`).join(', ')
|
||||
const binaryHint = keyFields.some(({type}) => type === 'B') ? ' Binary keys use base64.' : ''
|
||||
const numberHint = keyFields.some(({type}) => type === 'N') ? ' All number keys must be quoted to preserve precision.' : ''
|
||||
return `Required keys: ${keys || 'the table key attributes'}.${binaryHint}${numberHint} A matching key replaces the existing record.`
|
||||
}
|
||||
|
||||
function isDynamoKeyType(value: unknown): value is 'S' | 'N' | 'B' {
|
||||
return value === 'S' || value === 'N' || value === 'B'
|
||||
}
|
||||
|
||||
function PanelHeader({icon, eyebrow, title, detail}: {icon: React.ReactNode; eyebrow: string; title: string; detail: string}) {
|
||||
return (
|
||||
<div className="cosmos-panel-header">
|
||||
{icon}
|
||||
<span>
|
||||
<small>{eyebrow}</small>
|
||||
<strong>{title}</strong>
|
||||
<em>{detail}</em>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function summarize(item: NoSqlItem): string {
|
||||
return Object.entries(item.document)
|
||||
.slice(0, 3)
|
||||
.map(([name, value]) => `${name}: ${formatJson(value)}`)
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
function formatJson(value: unknown): string {
|
||||
return JSON.stringify(value) ?? String(value)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {LucideIcon} from 'lucide-react'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function EmptyState({icon: Icon, title, description, compact}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={`empty ${compact ? 'compact' : ''}`}>
|
||||
<div className="empty-icon">
|
||||
<Icon size={compact ? 18 : 24}/>
|
||||
</div>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {AlertTriangle, Download, Loader2, X} from 'lucide-react'
|
||||
import type {ObjectPreviewKind} from '@/lib/format'
|
||||
|
||||
interface ObjectPreviewModalProps {
|
||||
kind: ObjectPreviewKind
|
||||
name: string
|
||||
src: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** Text previews are fetched inline, so cap how much we pull and render. */
|
||||
const TEXT_PREVIEW_LIMIT = 200_000
|
||||
|
||||
/**
|
||||
* Lightbox/preview drawer for storage objects. Reuses the existing object
|
||||
* download URL as the media source — no new backend endpoint, per the
|
||||
* "never invent a custom protocol" rule in CONTRIBUTING.md. Image/video/audio
|
||||
* are rendered via native HTML media elements; text is fetched and shown as
|
||||
* plain text, since there is no inline "view" mode on the download endpoint.
|
||||
*/
|
||||
export function ImagePreviewModal({kind, name, src, onClose}: ObjectPreviewModalProps) {
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="copy-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="image-preview-modal">
|
||||
<div className="image-preview-header">
|
||||
<span className="image-preview-name" title={name}>{name}</span>
|
||||
<div className="image-preview-actions">
|
||||
<a className="icon-btn" href={src} title={`Download ${name}`}>
|
||||
<Download size={14}/>
|
||||
</a>
|
||||
<button className="icon-btn" type="button" onClick={onClose} title="Close" aria-label="Close">
|
||||
<X size={14}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="image-preview-body">
|
||||
{kind === 'image' && <ImagePreview src={src} name={name}/>}
|
||||
{kind === 'video' && <video src={src} controls autoPlay={false}/>}
|
||||
{kind === 'audio' && <audio src={src} controls autoPlay={false} style={{width: '100%'}}/>}
|
||||
{kind === 'text' && <TextPreview src={src}/>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImagePreview({src, name}: {src: string; name: string}) {
|
||||
const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>('loading')
|
||||
return (
|
||||
<>
|
||||
{status === 'loading' && <PreviewStatus label="Loading preview…"/>}
|
||||
{status === 'error' && <PreviewStatus label="Could not load a preview for this object." isError/>}
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
style={{display: status === 'loaded' ? 'block' : 'none'}}
|
||||
onLoad={() => setStatus('loaded')}
|
||||
onError={() => setStatus('error')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TextPreview({src}: {src: string}) {
|
||||
const [state, setState] = useState<{status: 'loading' | 'loaded' | 'error'; text: string; truncated: boolean}>({
|
||||
status: 'loading', text: '', truncated: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setState({status: 'loading', text: '', truncated: false})
|
||||
readTextPreview(src, controller.signal)
|
||||
.then((result) => setState({status: 'loaded', ...result}))
|
||||
.catch((err) => {
|
||||
if ((err as {name?: string}).name === 'AbortError') return
|
||||
setState({status: 'error', text: '', truncated: false})
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [src])
|
||||
|
||||
if (state.status === 'loading') return <PreviewStatus label="Loading preview…"/>
|
||||
if (state.status === 'error') return <PreviewStatus label="Could not load a preview for this object." isError/>
|
||||
|
||||
return (
|
||||
<pre className="object-text-preview">
|
||||
{state.text}
|
||||
{state.truncated && '\n\n… truncated'}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches only as much of the object as needed for the preview. Stops
|
||||
* reading (and cancels the underlying connection) once TEXT_PREVIEW_LIMIT
|
||||
* characters have been decoded, instead of buffering the full response —
|
||||
* important for large text-like objects, since res.text() would otherwise
|
||||
* transfer and hold the entire body in memory just to show the first slice.
|
||||
*/
|
||||
async function readTextPreview(src: string, signal: AbortSignal): Promise<{text: string; truncated: boolean}> {
|
||||
const res = await fetch(src, {signal})
|
||||
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let text = ''
|
||||
let truncated: boolean
|
||||
try {
|
||||
while (text.length < TEXT_PREVIEW_LIMIT) {
|
||||
const {done, value} = await reader.read()
|
||||
if (done) break
|
||||
text += decoder.decode(value, {stream: true})
|
||||
}
|
||||
if (text.length > TEXT_PREVIEW_LIMIT) {
|
||||
text = text.slice(0, TEXT_PREVIEW_LIMIT)
|
||||
// A code unit landing exactly on the cut could be the high half of a
|
||||
// surrogate pair (e.g. an emoji) with its low half past the boundary —
|
||||
// slice() doesn't know that, and leaves a lone surrogate that renders
|
||||
// as a replacement glyph. Drop it so the preview only ever ends on a
|
||||
// complete character.
|
||||
const lastCode = text.charCodeAt(text.length - 1)
|
||||
if (lastCode >= 0xd800 && lastCode <= 0xdbff) text = text.slice(0, -1)
|
||||
truncated = true
|
||||
} else {
|
||||
// Confirm whether more data was actually available before claiming
|
||||
// completeness — the loop can exit exactly at the limit.
|
||||
const {done} = await reader.read()
|
||||
truncated = !done
|
||||
}
|
||||
} finally {
|
||||
void reader.cancel().catch(() => {})
|
||||
}
|
||||
return {text, truncated}
|
||||
}
|
||||
|
||||
function PreviewStatus({label, isError}: {label: string; isError?: boolean}) {
|
||||
return (
|
||||
<div className="image-preview-status">
|
||||
{isError ? <AlertTriangle size={20}/> : <Loader2 size={20} className="spin"/>}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {FormEvent, useEffect, useState} from 'react'
|
||||
import {X} from 'lucide-react'
|
||||
|
||||
interface JsonRecordModalProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
initialValue: string
|
||||
isPending: boolean
|
||||
submitError?: string
|
||||
onClose: () => void
|
||||
onSubmit: (document: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
export function JsonRecordModal({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
initialValue,
|
||||
isPending,
|
||||
submitError,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: JsonRecordModalProps) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const [validationError, setValidationError] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setValue(initialValue)
|
||||
setValidationError(undefined)
|
||||
}, [initialValue, open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !isPending) onClose()
|
||||
}
|
||||
window.addEventListener('keydown', closeOnEscape)
|
||||
return () => window.removeEventListener('keydown', closeOnEscape)
|
||||
}, [isPending, onClose, open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
setValidationError('Record must be a JSON object.')
|
||||
return
|
||||
}
|
||||
setValidationError(undefined)
|
||||
onSubmit(parsed as Record<string, unknown>)
|
||||
} catch (error) {
|
||||
setValidationError(error instanceof Error ? error.message : 'Invalid JSON.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={(event) => event.target === event.currentTarget && !isPending && onClose()}>
|
||||
<form className="record-modal" role="dialog" aria-modal="true" aria-label={title} onSubmit={submit}>
|
||||
<div className="record-modal-header">
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" aria-label="Close" disabled={isPending} onClick={onClose}>
|
||||
<X size={15}/>
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className={`textarea code-textarea record-modal-editor ${validationError ? 'invalid' : ''}`}
|
||||
aria-label="Record JSON"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value)
|
||||
setValidationError(undefined)
|
||||
}}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
disabled={isPending}
|
||||
/>
|
||||
{(validationError || submitError) && <div className="form-error">{validationError ?? submitError}</div>}
|
||||
<div className="modal-footer">
|
||||
<button className="button" type="button" disabled={isPending} onClick={onClose}>Cancel</button>
|
||||
<button className="button primary" type="submit" disabled={isPending}>
|
||||
{isPending ? 'Adding' : 'Add record'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {act, fireEvent, render, screen} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {afterEach, describe, expect, test, vi} from "vitest";
|
||||
import {encryptKmsResource, type KmsEncryptResponse} from "@/api/cloudProxyClient";
|
||||
import {KmsCryptoPanel} from "@/components/KmsCryptoPanel";
|
||||
import {DEFAULT_ACCOUNT_ID, setAccountId} from "@/lib/accountStore";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
|
||||
vi.mock("@/api/cloudProxyClient", () => ({
|
||||
decryptKmsResource: vi.fn(),
|
||||
encryptKmsResource: vi.fn(),
|
||||
}));
|
||||
|
||||
const resource: CloudResource = {
|
||||
id: "key-1",
|
||||
name: "key-1",
|
||||
cloud: "aws",
|
||||
service: "kms",
|
||||
type: "key",
|
||||
region: "us-east-1",
|
||||
createdAt: null,
|
||||
status: "Enabled",
|
||||
metadata: {
|
||||
enabled: true,
|
||||
keySpec: "SYMMETRIC_DEFAULT",
|
||||
keyUsage: "ENCRYPT_DECRYPT",
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setAccountId(DEFAULT_ACCOUNT_ID);
|
||||
});
|
||||
|
||||
describe("KmsCryptoPanel", () => {
|
||||
test("clears sensitive state and ignores an old response when the account changes", async () => {
|
||||
setAccountId(DEFAULT_ACCOUNT_ID);
|
||||
let resolveEncrypt: ((value: KmsEncryptResponse) => void) | undefined;
|
||||
vi.mocked(encryptKmsResource).mockReturnValue(new Promise((resolve) => {
|
||||
resolveEncrypt = resolve;
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<KmsCryptoPanel cloud="aws" resource={resource} runtimeReachable={true}/>);
|
||||
|
||||
await user.type(screen.getByLabelText("Plaintext (UTF-8)"), "account-a-secret");
|
||||
fireEvent.change(screen.getByLabelText("Encryption context (optional JSON)"), {
|
||||
target: {value: '{"tenant":"a"}'},
|
||||
});
|
||||
const encryptButtons = screen.getAllByRole("button", {name: "Encrypt"});
|
||||
await user.click(encryptButtons[encryptButtons.length - 1]);
|
||||
expect(screen.getByRole("button", {name: "Working"})).toBeDisabled();
|
||||
|
||||
act(() => {
|
||||
setAccountId("111111111111");
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText("Plaintext (UTF-8)")).toHaveValue("");
|
||||
expect(screen.getByLabelText("Encryption context (optional JSON)")).toHaveValue("");
|
||||
const resetEncryptButtons = screen.getAllByRole("button", {name: "Encrypt"});
|
||||
expect(resetEncryptButtons[resetEncryptButtons.length - 1]).toBeEnabled();
|
||||
|
||||
await act(async () => {
|
||||
resolveEncrypt?.({
|
||||
ciphertextBlobBase64: "old-account-result",
|
||||
keyId: "key-1",
|
||||
encryptionAlgorithm: "SYMMETRIC_DEFAULT",
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("old-account-result")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {Copy, KeyRound, Loader2, LockKeyhole, UnlockKeyhole} from "lucide-react";
|
||||
import {
|
||||
decryptKmsResource,
|
||||
encryptKmsResource,
|
||||
type KmsEncryptionAlgorithm,
|
||||
} from "@/api/cloudProxyClient";
|
||||
import {useAccountId} from "@/lib/accountStore";
|
||||
import type {CloudProvider} from "@/types/cloud";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
|
||||
interface KmsCryptoPanelProps {
|
||||
cloud: CloudProvider;
|
||||
resource?: CloudResource;
|
||||
runtimeReachable: boolean;
|
||||
}
|
||||
|
||||
type CryptoMode = "encrypt" | "decrypt";
|
||||
type CryptoResult = {label: string; value: string};
|
||||
|
||||
export function KmsCryptoPanel({cloud, resource, runtimeReachable}: KmsCryptoPanelProps) {
|
||||
const accountId = useAccountId();
|
||||
const keySpec = metadataString(resource, "keySpec");
|
||||
const keyUsage = metadataString(resource, "keyUsage");
|
||||
const keyEnabled = resource?.status === "Enabled" && resource.metadata.enabled === true;
|
||||
const [mode, setMode] = useState<CryptoMode>("encrypt");
|
||||
const [plaintext, setPlaintext] = useState("");
|
||||
const [ciphertext, setCiphertext] = useState("");
|
||||
const [algorithm, setAlgorithm] = useState<KmsEncryptionAlgorithm>(defaultAlgorithm(keySpec));
|
||||
const [contextText, setContextText] = useState("");
|
||||
const [result, setResult] = useState<CryptoResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const activeRequest = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
activeRequest.current?.abort();
|
||||
activeRequest.current = null;
|
||||
setMode("encrypt");
|
||||
setPlaintext("");
|
||||
setCiphertext("");
|
||||
setAlgorithm(defaultAlgorithm(keySpec));
|
||||
setContextText("");
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPending(false);
|
||||
setCopied(false);
|
||||
return () => {
|
||||
activeRequest.current?.abort();
|
||||
activeRequest.current = null;
|
||||
};
|
||||
}, [accountId, resource?.id, keySpec]);
|
||||
|
||||
const isKmsKey = resource?.service === "kms" && resource.type === "key";
|
||||
const isSupportedKey = keyUsage === "ENCRYPT_DECRYPT" && keyEnabled && isEncryptKeySpec(keySpec);
|
||||
const canSubmit = Boolean(isKmsKey && isSupportedKey && runtimeReachable && !pending);
|
||||
const isRsa = keySpec === "RSA_2048" || keySpec === "RSA_4096";
|
||||
|
||||
const changeMode = (next: CryptoMode) => {
|
||||
if (next === mode) return;
|
||||
activeRequest.current?.abort();
|
||||
activeRequest.current = null;
|
||||
setMode(next);
|
||||
setPlaintext("");
|
||||
setCiphertext("");
|
||||
setContextText("");
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPending(false);
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!resource || !canSubmit) return;
|
||||
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setCopied(false);
|
||||
activeRequest.current?.abort();
|
||||
const controller = new AbortController();
|
||||
activeRequest.current = controller;
|
||||
setPending(true);
|
||||
try {
|
||||
const encryptionContext = isRsa ? undefined : parseEncryptionContext(contextText);
|
||||
if (mode === "encrypt") {
|
||||
if (!plaintext) throw new Error("Enter plaintext to encrypt.");
|
||||
const response = await encryptKmsResource(cloud, resource.id, {
|
||||
plaintextBase64: utf8ToBase64(plaintext),
|
||||
encryptionAlgorithm: algorithm,
|
||||
...(encryptionContext ? {encryptionContext} : {}),
|
||||
}, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setResult({label: "Ciphertext (Base64)", value: response.ciphertextBlobBase64});
|
||||
} else {
|
||||
if (!ciphertext.trim()) throw new Error("Enter Base64 ciphertext to decrypt.");
|
||||
const response = await decryptKmsResource(cloud, resource.id, {
|
||||
ciphertextBlobBase64: ciphertext.trim(),
|
||||
encryptionAlgorithm: algorithm,
|
||||
...(encryptionContext ? {encryptionContext} : {}),
|
||||
}, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setResult(decryptedResult(response.plaintextBase64));
|
||||
}
|
||||
} catch (submitError) {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(submitError instanceof Error ? submitError.message : "KMS operation failed.");
|
||||
} finally {
|
||||
if (activeRequest.current === controller) {
|
||||
activeRequest.current = null;
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyResult = async () => {
|
||||
if (!result) return;
|
||||
await navigator.clipboard.writeText(result.value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
};
|
||||
|
||||
if (!resource || resource.service !== "kms") {
|
||||
return (
|
||||
<section className="table-panel">
|
||||
<div className="empty compact">
|
||||
<h3>Select an encryption key</h3>
|
||||
<p>Select an enabled ENCRYPT_DECRYPT KMS key to encrypt or decrypt data.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">KMS Actions</p>
|
||||
<h3><KeyRound size={15} /> Encrypt and decrypt</h3>
|
||||
<p className="muted compact-text">
|
||||
Values stay in this panel and are cleared when you switch keys or operations.
|
||||
</p>
|
||||
</div>
|
||||
<span className={`runtime-state ${canSubmit ? "ready" : "pending"}`}>
|
||||
{canSubmit ? "Ready" : operationUnavailableReason(keyUsage, keyEnabled, keySpec, runtimeReachable)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="resource-create-inline">
|
||||
<div className="drawer-tabs">
|
||||
<button type="button" className={`drawer-tab ${mode === "encrypt" ? "active" : ""}`} onClick={() => changeMode("encrypt")}>
|
||||
<LockKeyhole size={13} /> Encrypt
|
||||
</button>
|
||||
<button type="button" className={`drawer-tab ${mode === "decrypt" ? "active" : ""}`} onClick={() => changeMode("decrypt")}>
|
||||
<UnlockKeyhole size={13} /> Decrypt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="metric-label" htmlFor="kms-encryption-algorithm">Encryption algorithm</label>
|
||||
<select
|
||||
id="kms-encryption-algorithm"
|
||||
className="input"
|
||||
value={algorithm}
|
||||
disabled={!isRsa}
|
||||
onChange={(event) => setAlgorithm(event.target.value as KmsEncryptionAlgorithm)}
|
||||
>
|
||||
{isRsa ? (
|
||||
<>
|
||||
<option value="RSAES_OAEP_SHA_256">RSAES_OAEP_SHA_256</option>
|
||||
<option value="RSAES_OAEP_SHA_1">RSAES_OAEP_SHA_1</option>
|
||||
</>
|
||||
) : (
|
||||
<option value="SYMMETRIC_DEFAULT">SYMMETRIC_DEFAULT</option>
|
||||
)}
|
||||
</select>
|
||||
|
||||
<label className="metric-label" htmlFor={`kms-${mode}-input`}>
|
||||
{mode === "encrypt" ? "Plaintext (UTF-8)" : "Ciphertext (Base64)"}
|
||||
</label>
|
||||
<textarea
|
||||
id={`kms-${mode}-input`}
|
||||
className="json-editor"
|
||||
value={mode === "encrypt" ? plaintext : ciphertext}
|
||||
onChange={(event) => mode === "encrypt" ? setPlaintext(event.target.value) : setCiphertext(event.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
style={{minHeight: 120}}
|
||||
/>
|
||||
|
||||
{!isRsa && (
|
||||
<>
|
||||
<label className="metric-label" htmlFor="kms-encryption-context">Encryption context (optional JSON)</label>
|
||||
<textarea
|
||||
id="kms-encryption-context"
|
||||
className="json-editor"
|
||||
value={contextText}
|
||||
onChange={(event) => setContextText(event.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={'{"purpose":"local-test"}'}
|
||||
style={{minHeight: 72}}
|
||||
/>
|
||||
<p className="muted compact-text">Encryption context is authenticated metadata and may appear in audit records. Do not put secrets in it.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button className="button primary" type="button" disabled={!canSubmit} onClick={() => void submit()}>
|
||||
{pending ? <Loader2 size={13} className="spin" /> : mode === "encrypt" ? <LockKeyhole size={13} /> : <UnlockKeyhole size={13} />}
|
||||
{pending ? "Working" : mode === "encrypt" ? "Encrypt" : "Decrypt"}
|
||||
</button>
|
||||
|
||||
{error && <p className="error-text compact-text">{error}</p>}
|
||||
{result && (
|
||||
<div className="inspector-section">
|
||||
<div className="inspector-section-header">
|
||||
<p className="metric-label">{result.label}</p>
|
||||
<button className="button" type="button" onClick={() => void copyResult()}>
|
||||
<Copy size={13} /> {copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="invoke-result success">{result.value}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function metadataString(resource: CloudResource | undefined, key: string): string | null {
|
||||
const value = resource?.metadata[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function defaultAlgorithm(keySpec: string | null): KmsEncryptionAlgorithm {
|
||||
return keySpec === "RSA_2048" || keySpec === "RSA_4096"
|
||||
? "RSAES_OAEP_SHA_256"
|
||||
: "SYMMETRIC_DEFAULT";
|
||||
}
|
||||
|
||||
function isEncryptKeySpec(keySpec: string | null): boolean {
|
||||
return keySpec === "SYMMETRIC_DEFAULT" || keySpec === "RSA_2048" || keySpec === "RSA_4096";
|
||||
}
|
||||
|
||||
function operationUnavailableReason(
|
||||
keyUsage: string | null,
|
||||
keyEnabled: boolean,
|
||||
keySpec: string | null,
|
||||
runtimeReachable: boolean,
|
||||
): string {
|
||||
if (!runtimeReachable) return "Runtime unavailable";
|
||||
if (keyUsage !== "ENCRYPT_DECRYPT") return "Unsupported key usage";
|
||||
if (!keyEnabled) return "Key not enabled";
|
||||
if (!isEncryptKeySpec(keySpec)) return "Unsupported key spec";
|
||||
return "Unavailable";
|
||||
}
|
||||
|
||||
function parseEncryptionContext(raw: string): Record<string, string> | undefined {
|
||||
if (!raw.trim()) return undefined;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Encryption context must be valid JSON.");
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Encryption context must be a JSON object of string values.");
|
||||
}
|
||||
const entries = Object.entries(value);
|
||||
if (entries.some(([, entryValue]) => typeof entryValue !== "string")) {
|
||||
throw new Error("Encryption context values must be strings.");
|
||||
}
|
||||
return entries.length === 0 ? undefined : Object.fromEntries(entries) as Record<string, string>;
|
||||
}
|
||||
|
||||
function utf8ToBase64(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
function decryptedResult(plaintextBase64: string): CryptoResult {
|
||||
try {
|
||||
const binary = window.atob(plaintextBase64);
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
return {label: "Plaintext (UTF-8)", value: new TextDecoder("utf-8", {fatal: true}).decode(bytes)};
|
||||
} catch {
|
||||
return {label: "Plaintext (Base64)", value: plaintextBase64};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
|
||||
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, Moon, Search, Sun} from 'lucide-react'
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import flociWhite from '@/assets/floci-white.svg'
|
||||
import flociBlack from '@/assets/floci-black.svg'
|
||||
import flociMarkWhite from '@/assets/floci-mark-white.svg'
|
||||
import flociMarkBlack from '@/assets/floci-mark-black.svg'
|
||||
import {useTheme} from '@/lib/useTheme'
|
||||
import {useSidebar} from '@/lib/useSidebar'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {getCloudStatus} from '@/api/cloudProxyClient'
|
||||
import {useCloudServicesQuery} from '@/api/queries/cloudQueries'
|
||||
import {AccountSwitcher} from '@/components/AccountSwitcher'
|
||||
import {serviceIcon} from '@/components/serviceIcons'
|
||||
import type {CloudProvider, CloudServiceDescriptor, RuntimeReachability} from '@/types/cloud'
|
||||
|
||||
/** The runtime's reachability, plus the state before the status query answers. */
|
||||
type ConnectionStatus = RuntimeReachability | 'unknown'
|
||||
|
||||
/**
|
||||
* Only a definitive answer gets a colour.
|
||||
*
|
||||
* `unknown` (status query still in flight — which is first paint and every
|
||||
* cloud switch) and `coming_soon` (runtime not wired) keep the neutral base
|
||||
* grey. Colouring anything that is not `reachable` red made the dot flash
|
||||
* green → red → green on each switch, and start red on load.
|
||||
*/
|
||||
function connectionDotClass(status: ConnectionStatus): string {
|
||||
if (status === 'reachable') return 'dot healthy'
|
||||
if (status === 'unavailable') return 'dot unavailable'
|
||||
return 'dot'
|
||||
}
|
||||
|
||||
/** Matches today's service count, so the real nav causes no layout jump. */
|
||||
const SKELETON_ROWS = 7
|
||||
|
||||
function NavItem({to, icon, label, collapsed}: { to: string; icon: React.ElementType; label: string; collapsed: boolean }) {
|
||||
const Icon = icon
|
||||
return (
|
||||
<NavLink className="nav-link" to={to} title={collapsed ? label : undefined}>
|
||||
<Icon size={14} aria-hidden="true"/>
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The nav is rendered entirely from `GET /clouds/:cloud/services`.
|
||||
*
|
||||
* It used to be a hardcoded item list plus a per-cloud boolean that never
|
||||
* consulted the server, so registering an adapter did not light up the nav and
|
||||
* availability could disagree with the API. Adding a service is now a catalog
|
||||
* row on the server and nothing here.
|
||||
*/
|
||||
function CloudServiceNav({collapsed}: {collapsed: boolean}) {
|
||||
const location = useLocation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const search = (searchParams.get('search') ?? '').trim().toLowerCase()
|
||||
const cloud = activeCloudFromPath(location.pathname)
|
||||
const cloudLabel = cloud.toUpperCase()
|
||||
const {data, isPending, isError, refetch, isFetching} = useCloudServicesQuery(cloud)
|
||||
|
||||
if (isPending) return <CloudServiceNavSkeleton cloudLabel={cloudLabel}/>
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="nav-section cloud-service-nav">
|
||||
<span className="nav-label">Cloud Services · {cloudLabel}</span>
|
||||
<div className="nav-link disabled nav-error">
|
||||
<AlertTriangle size={14} aria-hidden="true"/>
|
||||
<span>Services unavailable</span>
|
||||
</div>
|
||||
<button className="nav-retry" type="button" disabled={isFetching} onClick={() => void refetch()}>
|
||||
{isFetching ? 'Retrying…' : 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const allServices = data ?? []
|
||||
const filteredServices = search
|
||||
? allServices.filter((s) =>
|
||||
s.displayName.toLowerCase().includes(search) ||
|
||||
s.service.toLowerCase().includes(search) ||
|
||||
s.group.toLowerCase().includes(search)
|
||||
)
|
||||
: allServices
|
||||
|
||||
const groups = groupByGroup(filteredServices)
|
||||
|
||||
return (
|
||||
<div className="nav-section cloud-service-nav">
|
||||
<span className="nav-label">Cloud Services · {cloudLabel}</span>
|
||||
{filteredServices.length === 0 && search ? (
|
||||
<div className="nav-link disabled">
|
||||
<span>No matching services</span>
|
||||
</div>
|
||||
) : (
|
||||
groups.map(([group, services]) => (
|
||||
<div className="nav-group" key={group}>
|
||||
<span className="nav-group-label">{group}</span>
|
||||
{services.map((service) => (
|
||||
<CloudServiceNavItem key={service.service} cloud={cloud} service={service} collapsed={collapsed}/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CloudServiceNavItem({cloud, service, collapsed}: {cloud: CloudProvider; service: CloudServiceDescriptor; collapsed: boolean}) {
|
||||
const Icon = serviceIcon(service.iconKey)
|
||||
const reasonTitle = service.reason ? `${service.displayName} — ${service.reason}` : service.displayName
|
||||
|
||||
if (service.availability === 'available') {
|
||||
const target = service.route.startsWith('/')
|
||||
? service.route
|
||||
: `/cloud-explorer/${cloud}/${service.route}`
|
||||
return <NavItem to={target} icon={Icon} label={service.displayName} collapsed={collapsed}/>
|
||||
}
|
||||
|
||||
// Title duplicates the visible label when the sidebar is expanded, which WAVE
|
||||
// flags as redundant. Keep it on the rail, where the label is hidden.
|
||||
return (
|
||||
<div
|
||||
className="nav-link disabled"
|
||||
title={collapsed || service.reason ? reasonTitle : undefined}
|
||||
>
|
||||
<Icon size={14} aria-hidden="true"/>
|
||||
<span>{service.displayName}</span>
|
||||
<span className="nav-soon">Soon</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CloudServiceNavSkeleton({cloudLabel}: {cloudLabel: string}) {
|
||||
return (
|
||||
<div className="nav-section cloud-service-nav">
|
||||
<span className="nav-label">Cloud Services · {cloudLabel}</span>
|
||||
{Array.from({length: SKELETON_ROWS}, (_, index) => (
|
||||
<div className="nav-link nav-skeleton" key={index} aria-hidden="true">
|
||||
<span className="skeleton-bar"/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Preserves the server's ordering while bucketing into its groups. */
|
||||
function groupByGroup(services: CloudServiceDescriptor[]): Array<[string, CloudServiceDescriptor[]]> {
|
||||
const groups = new Map<string, CloudServiceDescriptor[]>()
|
||||
for (const service of services) {
|
||||
const existing = groups.get(service.group)
|
||||
if (existing) existing.push(service)
|
||||
else groups.set(service.group, [service])
|
||||
}
|
||||
return [...groups]
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const location = useLocation()
|
||||
const activeCloud = activeCloudFromPath(location.pathname)
|
||||
const {theme, toggle} = useTheme()
|
||||
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
|
||||
const isDark = theme === 'dark'
|
||||
const {data, isError} = useQuery({
|
||||
queryKey: ['cloud-status', activeCloud],
|
||||
queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
|
||||
refetchInterval: 5000
|
||||
})
|
||||
const status: ConnectionStatus = isError ? 'unavailable' : data?.runtime ?? 'unknown'
|
||||
const isConnected = status === 'reachable'
|
||||
const connectionLabel = isConnected ? 'Connected' : 'Not connected'
|
||||
const connectionTarget = data?.endpoint ?? activeCloud
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<a className="skip-link" href="#main-content">Skip to content</a>
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-inner">
|
||||
<div className="brand">
|
||||
<img className="brand-logo" src={isDark ? flociWhite : flociBlack} alt="Floci"/>
|
||||
<img className="brand-mark" src={isDark ? flociMarkWhite : flociMarkBlack} alt="" aria-hidden="true"/>
|
||||
<p>Local Cloud</p>
|
||||
</div>
|
||||
|
||||
<nav className="nav" aria-label="Console">
|
||||
<div className="nav-section">
|
||||
<span className="nav-label">General</span>
|
||||
<NavItem to={`/console/${activeCloud}`} icon={LayoutDashboard} label="Console Home" collapsed={collapsed}/>
|
||||
</div>
|
||||
<CloudServiceNav collapsed={collapsed}/>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<span className="sidebar-footer-text">Floci DevTools · Local</span>
|
||||
<button
|
||||
ref={toggleRef}
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
onClick={toggleSidebar}
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? <ChevronsRight size={14}/> : <ChevronsLeft size={14}/>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="shell">
|
||||
<header className="topbar">
|
||||
<TopbarSearch/>
|
||||
<button
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
title={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
>
|
||||
{isDark ? <Sun size={14} aria-hidden="true"/> : <Moon size={14} aria-hidden="true"/>}
|
||||
</button>
|
||||
<div id="topbar-status" className="topbar-status"/>
|
||||
<AccountSwitcher/>
|
||||
<div className={`connection ${isConnected ? 'connected' : 'disconnected'}`}>
|
||||
<span className={connectionDotClass(status)} aria-hidden="true"/>
|
||||
<span className="connection-state">{connectionLabel}</span>
|
||||
<span className="connection-target">{connectionTarget}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main id="main-content" className="main" tabIndex={-1}>
|
||||
<Outlet/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Topbar search bar.
|
||||
*
|
||||
* Reads the initial value from the `search` URL query parameter so the input
|
||||
* survives page refreshes and back/forward navigation. Writes back with a
|
||||
* 300 ms debounce so the URL is not updated on every keystroke.
|
||||
*
|
||||
* Pressing `/` when no other focusable input is active focuses this bar,
|
||||
* matching the keyboard hint shown in the widget.
|
||||
* Pressing Escape clears the query and blurs the input.
|
||||
*/
|
||||
function TopbarSearch() {
|
||||
const location = useLocation()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const initialQuery = searchParams.get('search') ?? ''
|
||||
const [draft, setDraft] = useState(initialQuery)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const commit = useCallback((value: string) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
if (value) {
|
||||
next.set('search', value)
|
||||
} else {
|
||||
next.delete('search')
|
||||
}
|
||||
return next
|
||||
},
|
||||
{replace: true},
|
||||
)
|
||||
}, [setSearchParams])
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
setDraft(value)
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => commit(value), 300)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
}
|
||||
setDraft('')
|
||||
commit('')
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up pending debounce timer on unmount. */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** Clear pending debounce and restore draft from destination URL search param on route change. */
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
}
|
||||
const urlSearch = searchParams.get('search') ?? ''
|
||||
setDraft(urlSearch)
|
||||
}, [location.pathname, searchParams])
|
||||
|
||||
/** Focus on `/` when no other input/textarea/select is active. */
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== '/') return
|
||||
const tag = (event.target as HTMLElement).tagName
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return
|
||||
event.preventDefault()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
/** Keep local draft in sync if the param changes externally (e.g. nav, browser history). */
|
||||
useEffect(() => {
|
||||
const external = searchParams.get('search') ?? ''
|
||||
setDraft((prev) => {
|
||||
if (prev !== external) {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
}
|
||||
return external
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [searchParams])
|
||||
|
||||
return (
|
||||
<div className="search">
|
||||
<Search size={14} aria-hidden="true"/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search services, features, docs, and more"
|
||||
aria-label="Search services, features, docs, and more"
|
||||
/>
|
||||
<span className="kbd" aria-hidden="true">/</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' {
|
||||
const match = pathname.match(/^\/(?:cloud-explorer|console)\/(aws|azure|gcp)(?:\/|$)/)
|
||||
return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp'
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {createPortal} from 'react-dom'
|
||||
import {ChevronRight, Maximize2, Minimize2, RefreshCw, ScrollText} from 'lucide-react'
|
||||
import {useQueries} from '@tanstack/react-query'
|
||||
import {listChildCollections, listCollectionItems} from '@/api/cloudProxyClient'
|
||||
import {EmptyState} from '@/components/EmptyState'
|
||||
import {formatBytes, formatDateTime} from '@/lib/format'
|
||||
import {timeAgo} from '@/lib/utils'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
interface LogsExplorerPanelProps {
|
||||
cloud: CloudProvider
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Lives in ResourceInspector next to a selected log group's metadata and the
|
||||
* Insights query panel, rather than as a full-width section under the
|
||||
* resource table. This is the two-level drill-in beneath a selected group:
|
||||
* streams (a child collection) and events (the leaf item), both served by the
|
||||
* generic child-collections SPI that AwsLogsAdapter implements — three
|
||||
* min-width columns (~1000px), too wide for the inspector's narrow column, so
|
||||
* the inline view is a compact summary and the real browser opens in the same
|
||||
* full-page modal the query panel uses.
|
||||
*
|
||||
* Both streams and events use one query per loaded cursor page (via
|
||||
* useQueries) rather than useInfiniteQuery, following the precedent set for
|
||||
* events by the original implementation this panel ported from. Refresh
|
||||
* resets the cursor array to a single page and explicitly refetches it,
|
||||
* rather than only refetching the first query: with more than one page
|
||||
* loaded, refetching in place would otherwise combine a fresh first page
|
||||
* with stale trailing pages built from the old cursor chain.
|
||||
*/
|
||||
export function LogsExplorerPanel({cloud, resource, runtimeReachable}: LogsExplorerPanelProps) {
|
||||
const groupId = resource?.id
|
||||
const [selectedStreamId, setSelectedStreamId] = useState<string | undefined>()
|
||||
const [streamCursors, setStreamCursors] = useState<Array<string | undefined>>([undefined])
|
||||
const [eventCursors, setEventCursors] = useState<Array<string | undefined>>([undefined])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedStreamId(undefined)
|
||||
setStreamCursors([undefined])
|
||||
setEventCursors([undefined])
|
||||
}, [cloud, groupId])
|
||||
|
||||
useEffect(() => {
|
||||
setEventCursors([undefined])
|
||||
}, [selectedStreamId])
|
||||
|
||||
const streamPages = useQueries({
|
||||
queries: streamCursors.map((cursor) => ({
|
||||
queryKey: ['log-streams', cloud, groupId, cursor],
|
||||
queryFn: ({signal}: {signal: AbortSignal}) => listChildCollections(cloud, 'logs', groupId ?? '', cursor, signal),
|
||||
enabled: Boolean(groupId) && runtimeReachable,
|
||||
})),
|
||||
})
|
||||
const firstStreamPage = streamPages[0]
|
||||
const lastStreamPage = streamPages[streamPages.length - 1]
|
||||
const streams = streamPages.flatMap((page) => page.data?.items ?? [])
|
||||
const nextStreamCursor = lastStreamPage?.data?.nextCursor ?? null
|
||||
const loadingMoreStreams = streamPages.length > 1 && lastStreamPage?.isLoading
|
||||
|
||||
const eventPages = useQueries({
|
||||
queries: eventCursors.map((cursor) => ({
|
||||
queryKey: ['log-events', cloud, groupId, selectedStreamId, cursor],
|
||||
queryFn: ({signal}: {signal: AbortSignal}) => listCollectionItems(cloud, 'logs', groupId ?? '', selectedStreamId ?? '', cursor, signal),
|
||||
enabled: Boolean(groupId && selectedStreamId) && runtimeReachable,
|
||||
})),
|
||||
})
|
||||
const firstEventPage = eventPages[0]
|
||||
const lastEventPage = eventPages[eventPages.length - 1]
|
||||
const events = eventPages.flatMap((page) => page.data?.items ?? [])
|
||||
const nextEventCursor = lastEventPage?.data?.nextCursor ?? null
|
||||
const loadingMoreEvents = eventPages.length > 1 && lastEventPage?.isLoading
|
||||
|
||||
function refreshStreams() {
|
||||
setStreamCursors([undefined])
|
||||
void firstStreamPage?.refetch()
|
||||
}
|
||||
|
||||
function refreshEvents() {
|
||||
setEventCursors([undefined])
|
||||
void firstEventPage?.refetch()
|
||||
}
|
||||
|
||||
if (!groupId) return null
|
||||
|
||||
const content = (
|
||||
<section className="cosmos-panel">
|
||||
<div className="cosmos-column">
|
||||
<div className="cosmos-panel-header">
|
||||
<ScrollText size={15}/>
|
||||
<span>
|
||||
<small>Streams</small>
|
||||
<strong>{groupId}</strong>
|
||||
<em>{streams.length} log streams</em>
|
||||
</span>
|
||||
<button className="icon-btn" type="button" title="Collapse" onClick={() => setExpanded(false)}>
|
||||
<Minimize2 size={14}/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="cosmos-toolbar">
|
||||
<button className="button" type="button" disabled={!runtimeReachable || firstStreamPage?.isFetching} onClick={refreshStreams}>
|
||||
<RefreshCw size={14}/>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{firstStreamPage?.error instanceof Error && <div className="form-error">{firstStreamPage.error.message}</div>}
|
||||
<div className="cosmos-list">
|
||||
{firstStreamPage?.isLoading && <div className="muted padded">Loading log streams</div>}
|
||||
{!firstStreamPage?.isLoading && streams.length === 0 && (
|
||||
<EmptyState
|
||||
icon={ScrollText}
|
||||
title="No log streams"
|
||||
description="Log streams created in this group will appear here."
|
||||
/>
|
||||
)}
|
||||
{streams.map((stream) => (
|
||||
<button
|
||||
key={stream.id}
|
||||
className={`cosmos-list-row ${selectedStreamId === stream.id ? 'selected' : ''}`}
|
||||
type="button"
|
||||
onClick={() => setSelectedStreamId(stream.id)}
|
||||
>
|
||||
<span>
|
||||
<strong>{stream.name}</strong>
|
||||
<small>Last event {timeAgo(typeof stream.metadata.lastEventTimestamp === 'string' ? stream.metadata.lastEventTimestamp : undefined)}</small>
|
||||
</span>
|
||||
<em>{typeof stream.metadata.storedBytes === 'number' ? formatBytes(stream.metadata.storedBytes) : '-'}</em>
|
||||
<ChevronRight size={13}/>
|
||||
</button>
|
||||
))}
|
||||
{nextStreamCursor && (
|
||||
<div style={{display: 'flex', justifyContent: 'center', padding: '12px 0'}}>
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
disabled={Boolean(loadingMoreStreams)}
|
||||
onClick={() => setStreamCursors((prev) => [...prev, nextStreamCursor])}
|
||||
>
|
||||
{loadingMoreStreams ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cosmos-column cosmos-column--wide">
|
||||
<div className="cosmos-panel-header">
|
||||
<ScrollText size={15}/>
|
||||
<span>
|
||||
<small>Events</small>
|
||||
<strong>{selectedStreamId ?? 'Select a stream'}</strong>
|
||||
<em>{events.length} events loaded</em>
|
||||
</span>
|
||||
</div>
|
||||
<div className="cosmos-toolbar">
|
||||
<button className="button" type="button" disabled={!runtimeReachable || !selectedStreamId || firstEventPage?.isFetching} onClick={refreshEvents}>
|
||||
<RefreshCw size={14}/>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{firstEventPage?.error instanceof Error && <div className="form-error">{firstEventPage.error.message}</div>}
|
||||
<div className="cosmos-items-table">
|
||||
{!selectedStreamId && (
|
||||
<div className="empty compact"><h3>Select a stream</h3><p>Events are scoped to a log stream.</p></div>
|
||||
)}
|
||||
{selectedStreamId && !firstEventPage?.isLoading && events.length === 0 && (
|
||||
<EmptyState
|
||||
icon={ScrollText}
|
||||
title="No log events"
|
||||
description="Events written to this stream will appear here."
|
||||
/>
|
||||
)}
|
||||
{selectedStreamId && (firstEventPage?.isLoading ? (
|
||||
<div className="muted padded">Loading log events</div>
|
||||
) : events.length > 0 && (
|
||||
<>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td className="mono" style={{color: '#8d9cad', whiteSpace: 'nowrap'}}>
|
||||
{formatDateTime(event.timestamp) ?? '-'}
|
||||
</td>
|
||||
<td className="mono">{String(event.body.message ?? '')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{nextEventCursor && (
|
||||
<div style={{display: 'flex', justifyContent: 'center', padding: '12px 0'}}>
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
disabled={Boolean(loadingMoreEvents)}
|
||||
onClick={() => setEventCursors((prev) => [...prev, nextEventCursor])}
|
||||
>
|
||||
{loadingMoreEvents ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
const summary = (
|
||||
<section className="logs-query-panel">
|
||||
<div className="logs-query-header">
|
||||
<ScrollText size={15}/>
|
||||
<span>
|
||||
<small>Streams & events</small>
|
||||
<strong>{groupId}</strong>
|
||||
<em>{firstStreamPage?.isLoading ? 'Loading…' : `${streams.length} log streams`}</em>
|
||||
</span>
|
||||
<button className="icon-btn" type="button" title="Expand to full page" onClick={() => setExpanded(true)}>
|
||||
<Maximize2 size={14}/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
if (!expanded) return summary
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-overlay" onClick={() => setExpanded(false)}>
|
||||
<div className="logs-query-modal" onClick={(event) => event.stopPropagation()}>
|
||||
{content}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {useEffect, useState, type KeyboardEvent} from 'react'
|
||||
import {createPortal} from 'react-dom'
|
||||
import {Maximize2, Minimize2, Play, Terminal} from 'lucide-react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {queryLogs} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {LogsInsightsQueryResult} from '@/types/resource'
|
||||
|
||||
const DEFAULT_QUERY = 'fields @timestamp, @message\n| sort @timestamp desc\n| limit 20'
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{label: '15m', minutes: 15},
|
||||
{label: '1h', minutes: 60},
|
||||
{label: '6h', minutes: 360},
|
||||
{label: '24h', minutes: 1440},
|
||||
]
|
||||
|
||||
interface LogsQueryPanelProps {
|
||||
cloud: CloudProvider
|
||||
logGroupName?: string
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Lives inside ResourceInspector next to a selected log group's metadata,
|
||||
* rather than as a full-width section a dev has to scroll the resource table
|
||||
* to find. The inspector column is narrow, so this expands into a full-page
|
||||
* modal on demand via a portal — the same mounted component either way, so
|
||||
* expanding/collapsing keeps the in-progress query text and results.
|
||||
*
|
||||
* Floci's supported StartQuery subset (fields/filter/sort/dedup/limit — see
|
||||
* docs/services/cloudwatch.md) has no UI of its own; this is that surface.
|
||||
* Styled after the real CloudWatch Logs Insights query editor (dark code box,
|
||||
* time-range chips, run-with-shortcut), minus the capabilities Floci doesn't
|
||||
* implement (stats/visualize, pattern analysis, live tail) — this repo's own
|
||||
* rule against decorative, unwired controls.
|
||||
*/
|
||||
export function LogsQueryPanel({cloud, logGroupName, runtimeReachable}: LogsQueryPanelProps) {
|
||||
const [queryText, setQueryText] = useState(DEFAULT_QUERY)
|
||||
const [rangeMinutes, setRangeMinutes] = useState(RANGE_OPTIONS[1].minutes)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const queryMut = useMutation({
|
||||
mutationFn: () => {
|
||||
const endTime = Math.floor(Date.now() / 1000)
|
||||
return queryLogs(cloud, logGroupName ?? '', {
|
||||
queryString: queryText,
|
||||
startTime: endTime - rangeMinutes * 60,
|
||||
endTime,
|
||||
})
|
||||
},
|
||||
})
|
||||
const {mutate: runQuery, reset: resetQuery} = queryMut
|
||||
|
||||
// ResourceInspector reuses this instance across row selections rather than
|
||||
// remounting it, so without this the previous group's results linger and
|
||||
// read as if they belong to the newly selected one.
|
||||
useEffect(() => {
|
||||
resetQuery()
|
||||
setQueryText(DEFAULT_QUERY)
|
||||
setRangeMinutes(RANGE_OPTIONS[1].minutes)
|
||||
setExpanded(false)
|
||||
}, [logGroupName, resetQuery])
|
||||
|
||||
if (!logGroupName) return null
|
||||
|
||||
function submit() {
|
||||
if (!queryText.trim()) return
|
||||
runQuery()
|
||||
}
|
||||
|
||||
function onEditorKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
|
||||
const panel = (
|
||||
<section className="logs-query-panel">
|
||||
<div className="logs-query-header">
|
||||
<Terminal size={15}/>
|
||||
<span>
|
||||
<small>Insights query</small>
|
||||
<strong>{logGroupName}</strong>
|
||||
<em>fields / filter / sort / dedup / limit</em>
|
||||
</span>
|
||||
<div className="logs-query-range" role="group" aria-label="Time range">
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.minutes}
|
||||
type="button"
|
||||
className={option.minutes === rangeMinutes ? 'active' : ''}
|
||||
disabled={!runtimeReachable}
|
||||
onClick={() => setRangeMinutes(option.minutes)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
title={expanded ? 'Collapse' : 'Expand to full page'}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? <Minimize2 size={14}/> : <Maximize2 size={14}/>}
|
||||
</button>
|
||||
</div>
|
||||
<div className="logs-query-editor-wrap">
|
||||
<textarea
|
||||
className="logs-query-textarea"
|
||||
value={queryText}
|
||||
onChange={(event) => setQueryText(event.target.value)}
|
||||
onKeyDown={onEditorKeyDown}
|
||||
spellCheck={false}
|
||||
rows={expanded ? 8 : 3}
|
||||
/>
|
||||
<div className="logs-query-actions">
|
||||
<span className="logs-query-shortcut">{macShortcut()}+Enter</span>
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={!runtimeReachable || queryMut.isPending || !queryText.trim()}
|
||||
onClick={submit}
|
||||
>
|
||||
<Play size={14}/>
|
||||
{queryMut.isPending ? 'Running' : 'Run query'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{queryMut.error instanceof Error && <div className="form-error">{queryMut.error.message}</div>}
|
||||
<LogsQueryResults result={queryMut.data} expanded={expanded}/>
|
||||
</section>
|
||||
)
|
||||
|
||||
if (!expanded) return panel
|
||||
|
||||
return createPortal(
|
||||
<div className="modal-overlay" onClick={() => setExpanded(false)}>
|
||||
<div className="logs-query-modal" onClick={(event) => event.stopPropagation()}>
|
||||
{panel}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
function macShortcut(): string {
|
||||
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'
|
||||
}
|
||||
|
||||
const STILL_RUNNING_STATUSES = new Set(['Running', 'Scheduled'])
|
||||
|
||||
function LogsQueryResults({result, expanded}: {result?: LogsInsightsQueryResult; expanded: boolean}) {
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="empty compact sql-results-empty">
|
||||
<h3>No query results</h3>
|
||||
<p>Run an Insights query to see matching log events.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Different log events discover different fields (a query with no `fields`
|
||||
// clause, or events whose shape varies row to row), so a field present only
|
||||
// on a later row must not be dropped just because row 0 lacked it.
|
||||
const columns = Array.from(new Set(result.rows.flatMap((row) => Object.keys(row))))
|
||||
const stillRunning = STILL_RUNNING_STATUSES.has(result.status)
|
||||
|
||||
return (
|
||||
<div className="sql-results">
|
||||
<div className="sql-results-summary">
|
||||
<span>Status: {result.status}</span>
|
||||
<span>{result.rows.length} rows</span>
|
||||
</div>
|
||||
{stillRunning && (
|
||||
<div className="muted padded">
|
||||
Floci hadn't finished this query when the console stopped waiting — these rows are a partial,
|
||||
in-progress result, not the full answer. Run the query again to see if it has finished
|
||||
(query id: {result.queryId}).
|
||||
</div>
|
||||
)}
|
||||
{!stillRunning && result.rows.length === 0 && <div className="muted padded">No events matched this query.</div>}
|
||||
{columns.length > 0 && (
|
||||
<div className={expanded ? 'sql-result-table-wrap logs-query-table-wrap--expanded' : 'sql-result-table-wrap'}>
|
||||
<table className="table sql-result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => <th key={column}>{column}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{columns.map((column) => (
|
||||
<td key={column} title={row[column]}>{row[column]}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
import {useState} from 'react'
|
||||
import {File, FileAudio, FileSpreadsheet, FileText, FileType, FileVideo, Image} from 'lucide-react'
|
||||
import type {ObjectIconKind} from '@/lib/format'
|
||||
|
||||
interface ObjectThumbnailProps {
|
||||
kind: ObjectIconKind
|
||||
src: string
|
||||
name: string
|
||||
/** Whether fetching object content (the Download action) is currently allowed. */
|
||||
canLoad: boolean
|
||||
}
|
||||
|
||||
const KIND_ICON: Record<Exclude<ObjectIconKind, 'image'>, typeof File> = {
|
||||
video: FileVideo,
|
||||
audio: FileAudio,
|
||||
text: FileText,
|
||||
pdf: FileType,
|
||||
document: FileText,
|
||||
spreadsheet: FileSpreadsheet,
|
||||
}
|
||||
|
||||
/**
|
||||
* Row icon for a recognized object type. Images get a real square thumbnail
|
||||
* (falling back to the generic file icon on load failure — e.g. the
|
||||
* extension heuristic guessed wrong). Every other recognized kind gets a
|
||||
* type-specific icon instead of a rendered thumbnail.
|
||||
*
|
||||
* When `canLoad` is false (download is gated off — runtime unavailable, or
|
||||
* the schema disables it), this never issues the image request: it falls
|
||||
* back to a static icon instead. Otherwise the thumbnail would fetch object
|
||||
* content behind the same capability check the Download button honors.
|
||||
*/
|
||||
export function ObjectThumbnail({kind, src, name, canLoad}: ObjectThumbnailProps) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
if (kind !== 'image' || failed || !canLoad) {
|
||||
const Icon = kind === 'image' ? Image : KIND_ICON[kind]
|
||||
return <Icon size={14}/>
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className="object-thumbnail"
|
||||
src={src}
|
||||
alt=""
|
||||
title={name}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import {useState} from 'react'
|
||||
import { K8sEngineDetails } from "@/features/k8s/K8sEngineDetails";
|
||||
import { LogsExplorerPanel } from "@/components/LogsExplorerPanel";
|
||||
import { LogsQueryPanel } from "@/components/LogsQueryPanel";
|
||||
import type { CloudProvider } from "@/types/cloud";
|
||||
import type { CloudResource, StorageObject } from "@/types/resource";
|
||||
import {formatBytes} from "@/lib/format";
|
||||
|
||||
interface ResourceInspectorProps {
|
||||
resource?: CloudResource;
|
||||
object?: StorageObject;
|
||||
cloud?: CloudProvider;
|
||||
runtimeReachable?: boolean;
|
||||
serviceName?: string;
|
||||
}
|
||||
|
||||
export function ResourceInspector({
|
||||
resource,
|
||||
object,
|
||||
cloud,
|
||||
runtimeReachable,
|
||||
serviceName,
|
||||
}: ResourceInspectorProps) {
|
||||
if (!resource) {
|
||||
return (
|
||||
<div className="resource-inspector empty compact">
|
||||
<p className="eyebrow">Resource Inspector</p>
|
||||
<h3>Select a resource</h3>
|
||||
<p>Inspect normalized metadata returned by the cloud proxy.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (object) {
|
||||
return (
|
||||
<aside className="resource-inspector">
|
||||
<div className="widget-header">
|
||||
<h3>{object.name}</h3>
|
||||
<span className="badge neutral">{object.type}</span>
|
||||
</div>
|
||||
<div className="inspector-grid">
|
||||
<InspectorItem label="Cloud" value={resource.cloud} />
|
||||
<InspectorItem label="Resource" value={resource.name} />
|
||||
<InspectorItem label="Key" value={object.key} />
|
||||
<InspectorItem
|
||||
label="Size"
|
||||
value={object.size === null ? "-" : formatBytes(object.size)}
|
||||
/>
|
||||
<InspectorItem
|
||||
label="Last Modified"
|
||||
value={object.lastModified ?? "-"}
|
||||
/>
|
||||
</div>
|
||||
<MetadataPanel metadata={object.metadata} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
if (resource.type === 'email') {
|
||||
return <EmailInspector key={resource.id} resource={resource} />
|
||||
}
|
||||
|
||||
const tags = getTags(resource.metadata.tags);
|
||||
const tagsUnavailable = getBooleanMetadata(resource.metadata.tagsUnavailable) ?? false;
|
||||
const versioning = getStringMetadata(resource.metadata.versioning);
|
||||
const versioningEnabled = getBooleanMetadata(
|
||||
resource.metadata.versioningEnabled,
|
||||
);
|
||||
const isDatabase =
|
||||
resource.service === "database" || resource.type === "db-instance";
|
||||
const isAwsDatabase = isDatabase && resource.cloud === "aws";
|
||||
const isK8sEngine = resource.service === "k8s" || resource.type === "cluster";
|
||||
const isLambda =
|
||||
resource.service === "serverless" || resource.type === "lambda";
|
||||
const isLogGroup = resource.cloud === "aws" && (resource.service === "logs" || resource.type === "log-group");
|
||||
|
||||
return (
|
||||
<aside className="resource-inspector">
|
||||
<div className="widget-header">
|
||||
<h3>{resource.name}</h3>
|
||||
<span className="badge neutral">{resource.type}</span>
|
||||
</div>
|
||||
<div className="inspector-grid">
|
||||
<InspectorItem label="Cloud" value={resource.cloud} />
|
||||
<InspectorItem label="Service" value={serviceName ?? resource.service} />
|
||||
<InspectorItem label="Region" value={resource.region ?? "-"} />
|
||||
<InspectorItem label="Created At" value={resource.createdAt ?? "-"} />
|
||||
{resource.status && (
|
||||
<InspectorItem label="Status" value={resource.status} />
|
||||
)}
|
||||
{resource.engine && (
|
||||
<InspectorItem label="Engine" value={resource.engine} />
|
||||
)}
|
||||
{resource.version && (
|
||||
<InspectorItem label="Version" value={resource.version} />
|
||||
)}
|
||||
{resource.instanceClass && (
|
||||
<InspectorItem label="Class" value={resource.instanceClass} />
|
||||
)}
|
||||
{versioning && <InspectorItem label="Versioning" value={versioning} />}
|
||||
{versioningEnabled !== null && (
|
||||
<InspectorItem
|
||||
label="Versioning Enabled"
|
||||
value={versioningEnabled ? "Yes" : "No"}
|
||||
/>
|
||||
)}
|
||||
<InspectorItem
|
||||
label="Tags"
|
||||
value={tagsUnavailable ? "Unavailable" : `${tags.length}`}
|
||||
/>
|
||||
{isLambda && (
|
||||
<>
|
||||
<InspectorItem
|
||||
label="Runtime"
|
||||
value={getStringMetadata(resource.metadata.runtime) ?? "-"}
|
||||
/>
|
||||
<InspectorItem
|
||||
label="Handler"
|
||||
value={getStringMetadata(resource.metadata.handler) ?? "-"}
|
||||
/>
|
||||
<InspectorItem
|
||||
label="Package Type"
|
||||
value={getStringMetadata(resource.metadata.packageType) ?? "-"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<section className="inspector-section">
|
||||
<p className="metric-label">Tags</p>
|
||||
{tagsUnavailable ? (
|
||||
<p className="muted compact-text">
|
||||
Tags unavailable: the provider denied or failed the tag lookup.
|
||||
</p>
|
||||
) : tags.length === 0 ? (
|
||||
<p className="muted compact-text">
|
||||
No tags returned for this resource.
|
||||
</p>
|
||||
) : (
|
||||
<div className="metadata-tags">
|
||||
{tags.map((tag) => (
|
||||
<span className="metadata-tag" key={`${tag.key}:${tag.value}`}>
|
||||
<strong>{tag.key}</strong>
|
||||
<span>{tag.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{isDatabase && (
|
||||
<DatabaseConnectionsSection metadata={resource.metadata} />
|
||||
)}
|
||||
{isAwsDatabase && <DatabaseLifecycleSection status={resource.status} />}
|
||||
{isDatabase && !isAwsDatabase && (
|
||||
<ProviderDatabaseSection cloud={resource.cloud} />
|
||||
)}
|
||||
{isK8sEngine && (
|
||||
<K8sEngineDetails cloud={resource.cloud} clusterName={resource.name} />
|
||||
)}
|
||||
{isLambda && (
|
||||
<section className="inspector-section">
|
||||
<p className="metric-label">Lambda Details</p>
|
||||
<div className="inspector-grid compact-grid">
|
||||
<InspectorItem
|
||||
label="ARN"
|
||||
value={getStringMetadata(resource.metadata.arn) ?? "-"}
|
||||
/>
|
||||
<InspectorItem
|
||||
label="Last Modified"
|
||||
value={getStringMetadata(resource.metadata.lastModified) ?? "-"}
|
||||
/>
|
||||
<InspectorItem
|
||||
label="Memory"
|
||||
value={
|
||||
getNumberMetadata(resource.metadata.memorySize) === null
|
||||
? "-"
|
||||
: `${getNumberMetadata(resource.metadata.memorySize)} MB`
|
||||
}
|
||||
/>
|
||||
<InspectorItem
|
||||
label="Timeout"
|
||||
value={
|
||||
getNumberMetadata(resource.metadata.timeout) === null
|
||||
? "-"
|
||||
: `${getNumberMetadata(resource.metadata.timeout)}s`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<pre className="metadata-block">
|
||||
{JSON.stringify(resource.metadata, null, 2)}
|
||||
</pre>
|
||||
<MetadataPanel metadata={resource.metadata} />
|
||||
{isLogGroup && cloud && (
|
||||
<>
|
||||
<LogsQueryPanel cloud={cloud} logGroupName={resource.id} runtimeReachable={runtimeReachable ?? false} />
|
||||
<LogsExplorerPanel cloud={cloud} resource={resource} runtimeReachable={runtimeReachable ?? false} />
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailInspector({resource}: {resource: CloudResource}) {
|
||||
const [tab, setTab] = useState<'preview' | 'text' | 'raw'>('preview')
|
||||
const source = getStringMetadata(resource.metadata.source) ?? '-'
|
||||
const toAddresses = getStringList(resource.metadata.toAddresses)
|
||||
const ccAddresses = getStringList(resource.metadata.ccAddresses)
|
||||
const bccAddresses = getStringList(resource.metadata.bccAddresses)
|
||||
const replyToAddresses = getStringList(resource.metadata.replyToAddresses)
|
||||
const textBody = getStringMetadata(resource.metadata.textBody)
|
||||
const htmlBody = getStringMetadata(resource.metadata.htmlBody)
|
||||
const rawData = getStringMetadata(resource.metadata.rawData)
|
||||
const hasPreview = Boolean(htmlBody || textBody)
|
||||
const activeTab = tab === 'preview' && !hasPreview ? (rawData ? 'raw' : 'text') : tab
|
||||
|
||||
return (
|
||||
<aside className="resource-inspector">
|
||||
<div className="widget-header">
|
||||
<h3 title={resource.name}>{resource.name}</h3>
|
||||
<span className="badge neutral">{getStringMetadata(resource.metadata.messageType) ?? 'email'}</span>
|
||||
</div>
|
||||
<div className="inspector-grid">
|
||||
<InspectorItem label="From" value={source} />
|
||||
<InspectorItem label="To" value={toAddresses.join(', ') || '-'} />
|
||||
{ccAddresses.length > 0 && <InspectorItem label="Cc" value={ccAddresses.join(', ')} />}
|
||||
{bccAddresses.length > 0 && <InspectorItem label="Bcc" value={bccAddresses.join(', ')} />}
|
||||
{replyToAddresses.length > 0 && <InspectorItem label="Reply-To" value={replyToAddresses.join(', ')} />}
|
||||
<InspectorItem label="Captured At" value={resource.createdAt ?? '-'} />
|
||||
</div>
|
||||
<section className="inspector-section">
|
||||
<div className="drawer-tabs" style={{marginBottom: 10}}>
|
||||
<button className={`drawer-tab ${activeTab === 'preview' ? 'active' : ''}`} disabled={!hasPreview} onClick={() => setTab('preview')}>Preview</button>
|
||||
<button className={`drawer-tab ${activeTab === 'text' ? 'active' : ''}`} disabled={!textBody} onClick={() => setTab('text')}>Text</button>
|
||||
<button className={`drawer-tab ${activeTab === 'raw' ? 'active' : ''}`} disabled={!rawData} onClick={() => setTab('raw')}>Raw</button>
|
||||
</div>
|
||||
{activeTab === 'preview' && htmlBody ? (
|
||||
<iframe
|
||||
title="Email HTML preview"
|
||||
sandbox=""
|
||||
referrerPolicy="no-referrer"
|
||||
srcDoc={safeEmailDocument(htmlBody)}
|
||||
style={{width: '100%', minHeight: 260, border: '1px solid var(--border)', borderRadius: 4, background: '#fff'}}
|
||||
/>
|
||||
) : activeTab === 'preview' ? (
|
||||
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', margin: 0}}>{textBody ?? 'No preview content captured.'}</pre>
|
||||
) : activeTab === 'text' ? (
|
||||
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', margin: 0}}>{textBody ?? 'No text body captured.'}</pre>
|
||||
) : (
|
||||
<pre className="metadata-block" style={{whiteSpace: 'pre-wrap', wordBreak: 'break-all', margin: 0}}>{rawData ?? 'No raw MIME data captured.'}</pre>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function safeEmailDocument(html: string): string {
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'"></head><body>${html}</body></html>`
|
||||
}
|
||||
|
||||
function ProviderDatabaseSection({ cloud }: { cloud: string }) {
|
||||
const label =
|
||||
cloud === "azure"
|
||||
? "Azure database"
|
||||
: cloud === "gcp"
|
||||
? "Google Cloud SQL"
|
||||
: "Database";
|
||||
|
||||
return (
|
||||
<section className="inspector-section">
|
||||
<p className="metric-label">Lifecycle</p>
|
||||
<p className="muted compact-text">
|
||||
{label} lifecycle and snapshot actions are exposed through the
|
||||
normalized resource metadata only.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseConnectionsSection({
|
||||
metadata,
|
||||
}: {
|
||||
metadata: Record<string, unknown>;
|
||||
}) {
|
||||
const endpoint = getRecordMetadata(metadata.endpoint);
|
||||
const subnetGroup = getRecordMetadata(metadata.subnetGroup);
|
||||
const securityGroups = getSecurityGroups(metadata.vpcSecurityGroups);
|
||||
const address = getStringMetadata(endpoint?.address);
|
||||
const port = getNumberMetadata(endpoint?.port);
|
||||
const vpcId = getStringMetadata(subnetGroup?.vpcId);
|
||||
const subnetGroupName = getStringMetadata(subnetGroup?.name);
|
||||
const subnetGroupStatus = getStringMetadata(subnetGroup?.status);
|
||||
|
||||
return (
|
||||
<section className="inspector-section">
|
||||
<p className="metric-label">Connections</p>
|
||||
<div className="inspector-grid compact-grid">
|
||||
<InspectorItem label="Endpoint" value={address ?? "-"} />
|
||||
<InspectorItem
|
||||
label="Port"
|
||||
value={port === null ? "-" : String(port)}
|
||||
/>
|
||||
<InspectorItem label="VPC" value={vpcId ?? "-"} />
|
||||
<InspectorItem label="Subnet Group" value={subnetGroupName ?? "-"} />
|
||||
<InspectorItem label="Subnet Status" value={subnetGroupStatus ?? "-"} />
|
||||
<InspectorItem
|
||||
label="Security Groups"
|
||||
value={`${securityGroups.length}`}
|
||||
/>
|
||||
</div>
|
||||
{securityGroups.length > 0 && (
|
||||
<div className="metadata-tags">
|
||||
{securityGroups.map((group) => (
|
||||
<span className="metadata-tag" key={`${group.id}:${group.status}`}>
|
||||
<strong>{group.id}</strong>
|
||||
<span>{group.status}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseLifecycleSection({ status }: { status?: string | null }) {
|
||||
return (
|
||||
<section className="inspector-section">
|
||||
<p className="metric-label">Lifecycle</p>
|
||||
<div className="lifecycle-actions">
|
||||
<button className="button compact" type="button" disabled>
|
||||
Start
|
||||
</button>
|
||||
<button className="button compact" type="button" disabled>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted compact-text">
|
||||
Current status: {status ?? "unknown"}. Start and stop are not supported
|
||||
by the local Floci RDS runtime.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function InspectorItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="metric-label">{label}</p>
|
||||
<p className="metric-value mono">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataPanel({ metadata }: { metadata: Record<string, unknown> }) {
|
||||
const rows = Object.entries(metadata).filter(
|
||||
([, value]) => value !== undefined && value !== null && value !== "",
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="metadata-block empty-metadata">
|
||||
<span>No metadata returned</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="metadata-block metadata-table">
|
||||
{rows.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span>{humanizeKey(key)}</span>
|
||||
<code>{formatMetadataValue(value)}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatMetadataValue(value: unknown): string {
|
||||
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
|
||||
function humanizeKey(value: string): string {
|
||||
return value
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
|
||||
function getStringMetadata(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function getStringList(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function getBooleanMetadata(value: unknown): boolean | null {
|
||||
return typeof value === "boolean" ? value : null;
|
||||
}
|
||||
|
||||
function getNumberMetadata(value: unknown): number | null {
|
||||
return typeof value === "number" ? value : null;
|
||||
}
|
||||
|
||||
function getRecordMetadata(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function getTags(value: unknown): Array<{ key: string; value: string }> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const tag = item as Record<string, unknown>;
|
||||
return typeof tag.key === "string" && typeof tag.value === "string"
|
||||
? [{ key: tag.key, value: tag.value }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
function getSecurityGroups(
|
||||
value: unknown,
|
||||
): Array<{ id: string; status: string }> {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const group = item as Record<string, unknown>;
|
||||
return typeof group.id === "string"
|
||||
? [
|
||||
{
|
||||
id: group.id,
|
||||
status: typeof group.status === "string" ? group.status : "-",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Pencil, Trash2} from 'lucide-react';
|
||||
import type {CloudResource} from '@/types/resource';
|
||||
import type {ServiceSchema} from '@/types/schema';
|
||||
import {getPath} from '@/lib/resourcePath';
|
||||
import {renderColumnValue} from '@/lib/columnFormat';
|
||||
|
||||
interface ResourceTableProps {
|
||||
schema: ServiceSchema;
|
||||
resources: CloudResource[];
|
||||
selectedId?: string;
|
||||
onSelect: (resource: CloudResource) => void;
|
||||
onEdit?: (resource: CloudResource) => void;
|
||||
onDelete: (resource: CloudResource) => void;
|
||||
deletingId?: string;
|
||||
dataPath?: (resource: CloudResource) => string | undefined;
|
||||
isDeleteMulti?: boolean;
|
||||
selectedItems?: CloudResource[];
|
||||
onToggleSelect?: (resource: CloudResource) => void;
|
||||
onToggleSelectAll?: () => void;
|
||||
setAllSelected?: (value: boolean) => void;
|
||||
isAllSelected?: boolean;
|
||||
}
|
||||
|
||||
export function ResourceTable({
|
||||
schema,
|
||||
resources,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deletingId,
|
||||
dataPath,
|
||||
isDeleteMulti,
|
||||
selectedItems,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
setAllSelected,
|
||||
isAllSelected = false,
|
||||
}: ResourceTableProps) {
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
const canDelete = schema.actions.includes('delete');
|
||||
const canEdit = schema.actions.includes('update') && Boolean(schema.updateFields?.length) && Boolean(onEdit);
|
||||
const hasActions = canDelete || canEdit || Boolean(dataPath && resources.some((resource) => dataPath(resource)));
|
||||
|
||||
if (resources.length === 0) {
|
||||
const emptyTitle = `No ${schema.displayName} found.`;
|
||||
const emptyDescription = `The connected runtime did not return any ${schema.displayName} resources.`;
|
||||
return (
|
||||
<div className="empty compact">
|
||||
<h3>{emptyTitle}</h3>
|
||||
<p>{emptyDescription}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table resource-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{isDeleteMulti && (
|
||||
<th style={{ width: '1%' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Select all resources"
|
||||
checked={isAllSelected && resources.length > 0}
|
||||
onChange={() => {
|
||||
if (onToggleSelectAll) {
|
||||
onToggleSelectAll();
|
||||
} else {
|
||||
setAllSelected?.(!isAllSelected);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
{schema.columns.map((column) => (
|
||||
<th key={column.name} style={column.width ? { width: column.width } : undefined}>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
{hasActions && <th aria-label="Actions" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resources.map((resource) => {
|
||||
const explorePath = dataPath?.(resource);
|
||||
return (
|
||||
<tr key={resource.id} className={selectedId === resource.id ? 'selected' : ''}>
|
||||
{isDeleteMulti && (
|
||||
<td style={{ width: '1%' }} onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${resource.name || resource.id}`}
|
||||
checked={selectedItems?.some((item) => item.id === resource.id) ?? false}
|
||||
onChange={() => {
|
||||
onToggleSelect?.(resource);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{schema.columns.map((column) => (
|
||||
<td key={column.name} onClick={() => onSelect(resource)}>
|
||||
{renderColumnValue(getPath(resource, column.path ?? column.name), column)}
|
||||
</td>
|
||||
))}
|
||||
{hasActions && (
|
||||
<td className="table-actions">
|
||||
<div className="table-actions-group">
|
||||
{explorePath && (
|
||||
<Link
|
||||
className="button compact"
|
||||
to={explorePath}
|
||||
aria-label={`Explore data in ${resource.name}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Explore data
|
||||
</Link>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
title={`Edit ${resource.name}`}
|
||||
aria-label={`Edit ${resource.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(resource);
|
||||
}}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
confirmId === resource.id ? (
|
||||
<div
|
||||
className="table-confirm-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="button danger compact"
|
||||
type="button"
|
||||
disabled={deletingId === resource.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(resource);
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
className="button success compact"
|
||||
type="button"
|
||||
disabled={deletingId === resource.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmId(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="icon-btn danger"
|
||||
type="button"
|
||||
title={`Delete ${resource.name}`}
|
||||
aria-label={`Delete ${resource.name}`}
|
||||
disabled={deletingId === resource.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmId(resource.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {ChevronDown, ChevronUp, Copy, Loader2, Play, Wand2, Zap} from "lucide-react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {invokeCloudResource} from "@/api/cloudProxyClient";
|
||||
import type {ServerlessInvokeResult} from "@/api/cloudProxyClient";
|
||||
import type {CloudProvider} from "@/types/cloud";
|
||||
import type {CloudResource} from "@/types/resource";
|
||||
|
||||
interface ServerlessInvokePanelProps {
|
||||
cloud: CloudProvider;
|
||||
resource?: CloudResource;
|
||||
runtimeReachable: boolean;
|
||||
}
|
||||
|
||||
export function ServerlessInvokePanel({
|
||||
cloud,
|
||||
resource,
|
||||
runtimeReachable,
|
||||
}: ServerlessInvokePanelProps) {
|
||||
const [payload, setPayload] = useState("{\n \n}");
|
||||
const [invokeResult, setInvokeResult] = useState<ServerlessInvokeResult | null>(null);
|
||||
const [showLog, setShowLog] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPayload("{\n \n}");
|
||||
setInvokeResult(null);
|
||||
setShowLog(false);
|
||||
setCopied(false);
|
||||
setValidationError(null);
|
||||
}, [resource?.id]);
|
||||
|
||||
const isSupportedResource =
|
||||
resource?.service === "serverless" &&
|
||||
(resource.type === "lambda" ||
|
||||
resource.type === "azure-function" ||
|
||||
resource.type === "gcp-function");
|
||||
|
||||
const canInvoke = Boolean(resource && isSupportedResource && runtimeReachable);
|
||||
const canSubmit = canInvoke && !validationError;
|
||||
|
||||
const providerLabel =
|
||||
cloud === "aws"
|
||||
? "Lambda function"
|
||||
: cloud === "azure"
|
||||
? "Azure Function"
|
||||
: "Google Cloud Function";
|
||||
|
||||
const invokeMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
invokeCloudResource(cloud, "serverless", resource!.id, payload),
|
||||
onSuccess: (result) => {
|
||||
setInvokeResult(result);
|
||||
setShowLog(false);
|
||||
setCopied(false);
|
||||
},
|
||||
});
|
||||
|
||||
const isInvokeError = Boolean(
|
||||
invokeResult?.functionError || (invokeResult && invokeResult.statusCode >= 400),
|
||||
);
|
||||
|
||||
const formatPayload = () => {
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(payload), null, 2);
|
||||
setPayload(formatted);
|
||||
setValidationError(null);
|
||||
} catch {
|
||||
setValidationError("Payload must be valid JSON before formatting.");
|
||||
}
|
||||
};
|
||||
|
||||
const clearPayload = () => {
|
||||
setPayload("{}");
|
||||
setValidationError(null);
|
||||
};
|
||||
|
||||
const copyResponse = async () => {
|
||||
if (!invokeResult) return;
|
||||
await navigator.clipboard.writeText(invokeResult.payload || "");
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
};
|
||||
|
||||
if (!resource || resource.service !== "serverless") {
|
||||
return (
|
||||
<section className="table-panel">
|
||||
<div className="empty compact">
|
||||
<h3>Select a serverless function</h3>
|
||||
<p>
|
||||
Select a Lambda function, Azure Function, or Google Cloud Function to
|
||||
invoke it from Cloud Explorer.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="table-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">Serverless Actions</p>
|
||||
<h3>
|
||||
<Zap size={15} />
|
||||
Invoke {providerLabel}
|
||||
</h3>
|
||||
<p className="muted compact-text">
|
||||
Send a JSON event payload to the selected serverless function.
|
||||
</p>
|
||||
</div>
|
||||
<span className={`runtime-state ${canInvoke ? "ready" : "pending"}`}>
|
||||
{canInvoke ? "Ready" : "Runtime unavailable"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="resource-create-inline">
|
||||
|
||||
<div className="inspector-section-header">
|
||||
<label className="metric-label" htmlFor="serverless-invoke-payload">
|
||||
Event payload JSON
|
||||
</label>
|
||||
|
||||
<button className="button" type="button" onClick={formatPayload}>
|
||||
<Wand2 size={13} />
|
||||
Format JSON
|
||||
</button>
|
||||
|
||||
<button className="button" type="button" onClick={clearPayload}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id="serverless-invoke-payload"
|
||||
className="json-editor serverless-payload-editor"
|
||||
value={payload}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setPayload(value);
|
||||
|
||||
try {
|
||||
JSON.parse(value);
|
||||
setValidationError(null);
|
||||
} catch {
|
||||
setValidationError("Payload must be valid JSON.");
|
||||
}
|
||||
}}
|
||||
spellCheck={false}
|
||||
placeholder="{}"
|
||||
style={{minHeight: 140}}
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="error-text compact-text">
|
||||
{validationError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={!canSubmit || invokeMutation.isPending}
|
||||
onClick={() => invokeMutation.mutate()}
|
||||
>
|
||||
{invokeMutation.isPending ? <Loader2 size={13} /> : <Play size={13} />}
|
||||
{invokeMutation.isPending ? "Invoking" : "Invoke"}
|
||||
</button>
|
||||
|
||||
{invokeMutation.isError && (
|
||||
<p className="error-text compact-text">
|
||||
{invokeMutation.error instanceof Error
|
||||
? invokeMutation.error.message
|
||||
: "Invocation failed"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{invokeResult && (
|
||||
<div className="inspector-section">
|
||||
<div className="inspector-section-header">
|
||||
<p className="metric-label">Invocation Result</p>
|
||||
<span className={`runtime-state ${isInvokeError ? "unavailable" : "ready"}`}>
|
||||
HTTP {invokeResult.statusCode}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{invokeResult.executionDuration !== undefined && (
|
||||
<p className="muted compact-text">
|
||||
Completed in {invokeResult.executionDuration}ms
|
||||
</p>
|
||||
)}
|
||||
|
||||
{invokeResult.functionError && (
|
||||
<p className="error-text compact-text">
|
||||
{invokeResult.functionError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button className="button" type="button" onClick={copyResponse}>
|
||||
<Copy size={13} />
|
||||
{copied ? "Copied" : "Copy response"}
|
||||
</button>
|
||||
|
||||
<pre className={`invoke-result ${isInvokeError ? "error" : "success"}`}>
|
||||
{tryFormatJson(invokeResult.payload) || "(empty)"}
|
||||
</pre>
|
||||
|
||||
{invokeResult.logResult && (
|
||||
<div>
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
onClick={() => setShowLog((open) => !open)}
|
||||
>
|
||||
Log tail
|
||||
{showLog ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||
</button>
|
||||
{showLog && <div className="log-tail">{invokeResult.logResult}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function tryFormatJson(raw: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {ChevronLeft, ChevronRight, Copy, Download, Eye, File, Folder, Loader2, RefreshCw, Search, Trash2, Upload, X} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {
|
||||
copyStorageObject,
|
||||
createCloudResource,
|
||||
deleteStorageObject,
|
||||
listCloudResources,
|
||||
listStorageObjects,
|
||||
storageObjectDownloadUrl,
|
||||
uploadStorageObject,
|
||||
} from '@/api/cloudProxyClient'
|
||||
import {capabilityEnabled, capabilityFor, normalizeCapabilities, withRuntimeState} from '@/lib/capabilities'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource, StorageObject} from '@/types/resource'
|
||||
import type {CapabilitySchema, ObjectActionName} from '@/types/schema'
|
||||
import {formatBytes, objectIconKind, objectPreviewKind} from '@/lib/format'
|
||||
import {ImagePreviewModal} from '@/components/ImagePreviewModal'
|
||||
import {ObjectThumbnail} from '@/components/ObjectThumbnail'
|
||||
|
||||
interface StorageObjectBrowserProps {
|
||||
cloud: CloudProvider
|
||||
resource?: CloudResource
|
||||
capabilities?: Array<CapabilitySchema<ObjectActionName> | ObjectActionName>
|
||||
runtimeReachable?: boolean
|
||||
selectedObjectKey?: string
|
||||
onSelectObject: (object?: StorageObject) => void
|
||||
}
|
||||
|
||||
export function StorageObjectBrowser({cloud, resource, capabilities = [], runtimeReachable = false, selectedObjectKey, onSelectObject}: StorageObjectBrowserProps) {
|
||||
const qc = useQueryClient()
|
||||
const fileRef = useRef<HTMLInputElement | null>(null)
|
||||
const [prefix, setPrefix] = useState('')
|
||||
const [uploadPrefix, setUploadPrefix] = useState('')
|
||||
const [folderName, setFolderName] = useState('')
|
||||
const [createFolderOpen, setCreateFolderOpen] = useState(false)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
const [copyObject, setCopyObject] = useState<StorageObject | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false)
|
||||
const [previewObject, setPreviewObject] = useState<StorageObject | null>(null)
|
||||
|
||||
const resolvedCapabilities = withRuntimeState(normalizeCapabilities(capabilities), runtimeReachable)
|
||||
const uploadCapability = capabilityFor(resolvedCapabilities, 'upload')
|
||||
const downloadCapability = capabilityFor(resolvedCapabilities, 'download')
|
||||
const deleteCapability = capabilityFor(resolvedCapabilities, 'delete')
|
||||
const createFolderCapability = capabilityFor(resolvedCapabilities, 'createFolder')
|
||||
const copyCapability = capabilityFor(resolvedCapabilities, 'copy')
|
||||
const canUpload = capabilityEnabled(uploadCapability)
|
||||
const canDownload = capabilityEnabled(downloadCapability)
|
||||
const canDelete = capabilityEnabled(deleteCapability)
|
||||
const canCreateFolder = capabilityEnabled(createFolderCapability)
|
||||
const canCopy = capabilityEnabled(copyCapability)
|
||||
|
||||
// Runtime reachability can flip while a preview is already open (it's polled
|
||||
// independently of this component). Re-checking canDownload only at the
|
||||
// moment the Eye button is clicked isn't enough — close an already-open
|
||||
// preview the instant download capability is revoked, so its modal can't
|
||||
// keep serving object content (img/video/audio src, the Download link,
|
||||
// an in-flight text fetch) after the gate that allowed it closes.
|
||||
useEffect(() => {
|
||||
if (!canDownload) setPreviewObject(null)
|
||||
}, [canDownload])
|
||||
|
||||
useEffect(() => {
|
||||
setPrefix('')
|
||||
setUploadPrefix('')
|
||||
setFolderName('')
|
||||
setCreateFolderOpen(false)
|
||||
setDeleteConfirm(null)
|
||||
setCopyObject(null)
|
||||
setSearch('')
|
||||
setSelectedKeys(new Set())
|
||||
setPreviewObject(null)
|
||||
onSelectObject(undefined)
|
||||
}, [resource?.id, onSelectObject])
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['storage-objects', cloud, resource?.id, prefix],
|
||||
queryFn: ({signal}) => listStorageObjects(cloud, resource?.id ?? '', prefix, signal),
|
||||
enabled: !!resource,
|
||||
})
|
||||
|
||||
const uploadMut = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!resource) return
|
||||
const key = `${uploadPrefix || prefix}${file.name}`
|
||||
await uploadStorageObject(cloud, resource.id, key, file)
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}),
|
||||
})
|
||||
|
||||
const createFolderMut = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
if (!resource) return
|
||||
const folderKey = `${prefix}${normalizeFolderName(name)}`
|
||||
await uploadStorageObject(cloud, resource.id, folderKey, new Blob([], {type: 'application/x-directory'}))
|
||||
},
|
||||
onSuccess: () => {
|
||||
setFolderName('')
|
||||
setCreateFolderOpen(false)
|
||||
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (object: StorageObject) => {
|
||||
if (!resource) return
|
||||
await deleteStorageObject(cloud, resource.id, object.key)
|
||||
},
|
||||
onSuccess: (_, deleted) => {
|
||||
if (selectedObjectKey === deleted.key) onSelectObject(undefined)
|
||||
qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
|
||||
},
|
||||
})
|
||||
|
||||
const moveCopyMut = useMutation({
|
||||
mutationFn: async ({srcKey, destKey, destResourceId, mode}: {
|
||||
srcKey: string; destKey: string; destResourceId?: string; mode: 'move' | 'copy'
|
||||
}) => {
|
||||
if (!resource) return
|
||||
await copyStorageObject(cloud, resource.id, srcKey, destKey, destResourceId)
|
||||
if (mode === 'move') await deleteStorageObject(cloud, resource.id, srcKey)
|
||||
},
|
||||
onSuccess: (_, vars) => {
|
||||
setCopyObject(null)
|
||||
if (vars.mode === 'move') onSelectObject(undefined)
|
||||
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
|
||||
if (vars.destResourceId) void qc.invalidateQueries({queryKey: ['storage-objects', cloud, vars.destResourceId]})
|
||||
},
|
||||
})
|
||||
|
||||
// ── Navigation helpers ──
|
||||
function navigate(nextPrefix: string) {
|
||||
setPrefix(nextPrefix)
|
||||
setSearch('')
|
||||
setSelectedKeys(new Set())
|
||||
setDeleteConfirm(null)
|
||||
setPreviewObject(null)
|
||||
onSelectObject(undefined)
|
||||
}
|
||||
|
||||
// ── Bulk delete ──
|
||||
async function handleBulkDelete() {
|
||||
if (!resource || selectedKeys.size === 0) return
|
||||
setBulkDeleting(true)
|
||||
try {
|
||||
await Promise.all([...selectedKeys].map((key) => deleteStorageObject(cloud, resource.id, key)))
|
||||
if (selectedObjectKey && selectedKeys.has(selectedObjectKey)) onSelectObject(undefined)
|
||||
setSelectedKeys(new Set())
|
||||
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource.id]})
|
||||
} finally {
|
||||
setBulkDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!resource) {
|
||||
return (
|
||||
<section className="object-browser empty compact">
|
||||
<h3>Select a storage resource</h3>
|
||||
<p>Choose a bucket or container to browse objects and blobs.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const objects = query.data?.objects ?? []
|
||||
const objectLabel = cloud === 'azure' ? 'blobs' : 'objects'
|
||||
|
||||
// ── Filtered data ──
|
||||
const q = search.toLowerCase()
|
||||
const filteredObjects = q
|
||||
? objects.filter((o) => o.name.toLowerCase().includes(q) || o.key.toLowerCase().includes(q))
|
||||
: objects
|
||||
const filteredFolders = filteredObjects.filter((o) => o.type === 'folder')
|
||||
const filteredFiles = filteredObjects.filter((o) => o.type === 'object')
|
||||
const totalFolders = objects.filter((o) => o.type === 'folder').length
|
||||
const totalFiles = objects.filter((o) => o.type === 'object').length
|
||||
|
||||
// ── Bulk select state ──
|
||||
const allSelected = filteredFiles.length > 0 && filteredFiles.every((f) => selectedKeys.has(f.key))
|
||||
const someSelected = !allSelected && filteredFiles.some((f) => selectedKeys.has(f.key))
|
||||
|
||||
const error = query.error ?? uploadMut.error ?? createFolderMut.error ?? deleteMut.error ?? moveCopyMut.error
|
||||
|
||||
return (
|
||||
<section className="object-browser">
|
||||
{previewObject && resource && objectPreviewKind(previewObject.name) && (
|
||||
<ImagePreviewModal
|
||||
kind={objectPreviewKind(previewObject.name)!}
|
||||
name={previewObject.name}
|
||||
src={storageObjectDownloadUrl(cloud, resource.id, previewObject.key)}
|
||||
onClose={() => setPreviewObject(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{copyObject && resource && (
|
||||
<MoveOrCopyModal
|
||||
cloud={cloud}
|
||||
resource={resource}
|
||||
srcObject={copyObject}
|
||||
canCopy={canCopy}
|
||||
isPending={moveCopyMut.isPending}
|
||||
error={moveCopyMut.error instanceof Error ? moveCopyMut.error.message : null}
|
||||
onClose={() => { setCopyObject(null); moveCopyMut.reset() }}
|
||||
onConfirm={(destKey, destResourceId, mode) => moveCopyMut.mutate({srcKey: copyObject.key, destKey, destResourceId, mode})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="object-browser-header">
|
||||
<div>
|
||||
<p className="eyebrow">Objects</p>
|
||||
<h3>{resource.name}</h3>
|
||||
<div className="object-browser-subtitle">
|
||||
<ObjectBreadcrumb prefix={prefix} onNavigate={navigate}/>
|
||||
<span>{totalFolders} folders, {totalFiles} {objectLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="object-browser-actions">
|
||||
{prefix && (
|
||||
<button className="button" type="button" onClick={() => navigate(parentPrefix(prefix))}>
|
||||
<ChevronLeft size={14}/>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
{createFolderCapability && (
|
||||
<button className="button" type="button" disabled={!canCreateFolder} title={createFolderCapability.reason} onClick={() => setCreateFolderOpen((open) => !open)}>
|
||||
<Folder size={14}/>
|
||||
New folder
|
||||
</button>
|
||||
)}
|
||||
{uploadCapability && (
|
||||
<>
|
||||
<input className="input object-prefix-input" value={uploadPrefix} onChange={(event) => setUploadPrefix(normalizePrefix(event.target.value))} placeholder={prefix ? `Upload to ${prefix}` : 'Upload prefix'}/>
|
||||
<input ref={fileRef} type="file" hidden onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) uploadMut.mutate(file)
|
||||
event.currentTarget.value = ''
|
||||
}}/>
|
||||
<button className="button" type="button" disabled={!canUpload} title={uploadCapability.reason} onClick={() => fileRef.current?.click()}>
|
||||
<Upload size={14}/>
|
||||
{uploadMut.isPending ? 'Uploading' : 'Upload'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="button" type="button" disabled={query.isFetching} onClick={() => query.refetch()}>
|
||||
<RefreshCw size={14}/>
|
||||
{query.isFetching ? 'Loading' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Create folder ── */}
|
||||
{createFolderOpen && (
|
||||
<form className="object-create-folder" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (folderName.trim()) createFolderMut.mutate(folderName)
|
||||
}}>
|
||||
<label>
|
||||
<span>Folder name</span>
|
||||
<input className="input" value={folderName} onChange={(event) => setFolderName(event.target.value)} placeholder={prefix ? `${prefix}new-folder/` : 'new-folder/'}/>
|
||||
</label>
|
||||
<button className="button primary" type="submit" disabled={createFolderMut.isPending || !folderName.trim()}>
|
||||
<Folder size={14}/>
|
||||
{createFolderMut.isPending ? 'Creating' : 'Create folder'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ── Error ── */}
|
||||
{error && (
|
||||
<div className="inline-error">
|
||||
{error instanceof Error ? error.message : 'Storage operation failed'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Search bar ── */}
|
||||
{!query.isLoading && objects.length > 0 && (
|
||||
<div className="object-search-bar">
|
||||
<Search size={13}/>
|
||||
<input
|
||||
className="input"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setSelectedKeys(new Set()) }}
|
||||
placeholder={`Search ${objectLabel} and folders…`}
|
||||
/>
|
||||
{search && (
|
||||
<button className="icon-btn" type="button" onClick={() => setSearch('')} aria-label="Clear search" title="Clear search">
|
||||
<X size={13} aria-hidden="true"/>
|
||||
</button>
|
||||
)}
|
||||
{search && (
|
||||
<span style={{fontSize: 12, color: 'var(--text-3)', whiteSpace: 'nowrap'}}>
|
||||
{filteredObjects.length} / {objects.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Table ── */}
|
||||
{query.isLoading ? (
|
||||
<div className="empty compact">
|
||||
<h3>Loading objects</h3>
|
||||
<p>Reading objects from the selected storage resource.</p>
|
||||
</div>
|
||||
) : objects.length === 0 ? (
|
||||
<div className="empty compact">
|
||||
<h3>No {objectLabel}</h3>
|
||||
<p>{prefix ? `The ${prefix} prefix is empty.` : `This ${resource.type} has no ${objectLabel} yet.`}</p>
|
||||
</div>
|
||||
) : filteredObjects.length === 0 ? (
|
||||
<div className="empty compact">
|
||||
<h3>No results</h3>
|
||||
<p>Nothing matches “{search}”.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="table object-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width: 32, textAlign: 'center'}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
ref={(el) => { if (el) el.indeterminate = someSelected }}
|
||||
checked={allSelected}
|
||||
title={allSelected ? 'Deselect all' : 'Select all files'}
|
||||
onChange={() => {
|
||||
if (allSelected) setSelectedKeys(new Set())
|
||||
else setSelectedKeys(new Set(filteredFiles.map((f) => f.key)))
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th aria-label="Actions"/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredFolders.map((object) => (
|
||||
<tr key={object.key}>
|
||||
<td/>
|
||||
<td onClick={() => navigate(object.key)} style={{cursor: 'pointer'}}>
|
||||
<span className="object-name">
|
||||
<Folder size={14}/>
|
||||
{object.name}
|
||||
<ChevronRight size={12}/>
|
||||
</span>
|
||||
</td>
|
||||
<td>{object.type}</td>
|
||||
<td>—</td>
|
||||
<td>{object.lastModified ?? '—'}</td>
|
||||
<td/>
|
||||
</tr>
|
||||
))}
|
||||
{filteredFiles.map((object) => {
|
||||
const isSelected = selectedKeys.has(object.key)
|
||||
const iconKind = objectIconKind(object.name)
|
||||
const previewKind = objectPreviewKind(object.name)
|
||||
return (
|
||||
<tr
|
||||
key={object.key}
|
||||
className={[
|
||||
selectedObjectKey === object.key ? 'selected' : '',
|
||||
isSelected ? 'bulk-selected' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<td style={{textAlign: 'center'}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selectedKeys)
|
||||
if (e.target.checked) next.add(object.key)
|
||||
else next.delete(object.key)
|
||||
setSelectedKeys(next)
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td onClick={() => onSelectObject(object)} style={{cursor: 'pointer'}}>
|
||||
<span className="object-name">
|
||||
{iconKind ? (
|
||||
<ObjectThumbnail kind={iconKind} src={storageObjectDownloadUrl(cloud, resource.id, object.key)} name={object.name} canLoad={canDownload}/>
|
||||
) : (
|
||||
<File size={14}/>
|
||||
)}
|
||||
{object.name}
|
||||
</span>
|
||||
</td>
|
||||
<td>{object.type}</td>
|
||||
<td>{object.size === null ? '—' : formatBytes(object.size)}</td>
|
||||
<td>{object.lastModified ?? '—'}</td>
|
||||
<td className="table-actions">
|
||||
{previewKind && (
|
||||
<button
|
||||
className="icon-btn"
|
||||
type="button"
|
||||
disabled={!canDownload}
|
||||
title={canDownload ? `Preview ${object.name}` : (downloadCapability?.reason ?? `Preview ${object.name}`)}
|
||||
onClick={(e) => { e.stopPropagation(); if (canDownload) setPreviewObject(object) }}
|
||||
>
|
||||
<Eye size={13}/>
|
||||
</button>
|
||||
)}
|
||||
{downloadCapability && (
|
||||
<a className={`icon-btn ${canDownload ? '' : 'disabled'}`} href={canDownload ? storageObjectDownloadUrl(cloud, resource.id, object.key) : undefined} title={downloadCapability.reason ?? `Download ${object.name}`}>
|
||||
<Download size={13}/>
|
||||
</a>
|
||||
)}
|
||||
{copyCapability && (
|
||||
<button className="icon-btn" disabled={!canCopy} title={copyCapability.reason ?? `Move / Copy ${object.name}`} onClick={() => setCopyObject(object)}>
|
||||
<Copy size={13}/>
|
||||
</button>
|
||||
)}
|
||||
{canDelete && deleteConfirm === object.key ? (
|
||||
<button className="button danger compact" disabled={deleteMut.isPending} onClick={() => {
|
||||
deleteMut.mutate(object)
|
||||
setDeleteConfirm(null)
|
||||
}}>
|
||||
Confirm
|
||||
</button>
|
||||
) : (
|
||||
deleteCapability && (
|
||||
<button className="icon-btn danger" disabled={!canDelete} title={deleteCapability.reason ?? `Delete ${object.name}`} onClick={() => setDeleteConfirm(object.key)}>
|
||||
<Trash2 size={13}/>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* ── Bulk action bar ── */}
|
||||
{selectedKeys.size > 0 && (
|
||||
<div className="bulk-bar">
|
||||
<span className="bulk-count">
|
||||
{selectedKeys.size} {objectLabel.replace(/s$/, '')}{selectedKeys.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<span className="bulk-spacer"/>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => setSelectedKeys(new Set())}
|
||||
disabled={bulkDeleting}
|
||||
>
|
||||
Deselect all
|
||||
</button>
|
||||
{deleteCapability && (
|
||||
<button
|
||||
className="button danger"
|
||||
disabled={!canDelete || bulkDeleting}
|
||||
title={deleteCapability.reason}
|
||||
onClick={() => void handleBulkDelete()}
|
||||
>
|
||||
{bulkDeleting ? <Loader2 size={13}/> : <Trash2 size={13}/>}
|
||||
Delete {selectedKeys.size}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Move / Copy modal ────────────────────────────────────────────────────────
|
||||
|
||||
const NEW_RESOURCE_SENTINEL = '__new__'
|
||||
|
||||
function MoveOrCopyModal({
|
||||
cloud,
|
||||
resource,
|
||||
srcObject,
|
||||
canCopy,
|
||||
isPending,
|
||||
error,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
cloud: CloudProvider
|
||||
resource: CloudResource
|
||||
srcObject: StorageObject
|
||||
canCopy: boolean
|
||||
isPending: boolean
|
||||
error: string | null
|
||||
onClose: () => void
|
||||
onConfirm: (destKey: string, destResourceId: string | undefined, mode: 'move' | 'copy') => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const resourceLabel = cloud === 'azure' ? 'container' : 'bucket'
|
||||
const [mode, setMode] = useState<'move' | 'copy'>('move')
|
||||
const [destKey, setDestKey] = useState(() => {
|
||||
const parts = srcObject.key.split('/')
|
||||
const filename = parts.pop() ?? srcObject.key
|
||||
const dir = parts.join('/')
|
||||
return dir ? `${dir}/copy-of-${filename}` : `copy-of-${filename}`
|
||||
})
|
||||
const [destResourceId, setDestResourceId] = useState(resource.id)
|
||||
const [newResourceName, setNewResourceName] = useState('')
|
||||
|
||||
const resourcesQuery = useQuery({
|
||||
queryKey: ['cloud-resources', cloud, 'storage'],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, 'storage', undefined, signal),
|
||||
})
|
||||
|
||||
const createResourceMut = useMutation({
|
||||
mutationFn: () => {
|
||||
const nameField = cloud === 'azure' ? 'containerName' : 'bucketName'
|
||||
return createCloudResource(cloud, 'storage', {[nameField]: newResourceName.trim()})
|
||||
},
|
||||
onSuccess: (created) => {
|
||||
setDestResourceId(created.id)
|
||||
setNewResourceName('')
|
||||
void qc.invalidateQueries({queryKey: ['cloud-resources', cloud, 'storage']})
|
||||
},
|
||||
})
|
||||
|
||||
const isCreatingNew = destResourceId === NEW_RESOURCE_SENTINEL
|
||||
const resources = resourcesQuery.data ?? []
|
||||
const resolvedDestId = isCreatingNew ? undefined : (destResourceId !== resource.id ? destResourceId : undefined)
|
||||
|
||||
return (
|
||||
<div className="copy-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="copy-modal">
|
||||
<div className="drawer-tabs" style={{marginBottom: 14}}>
|
||||
<button className={`drawer-tab ${mode === 'move' ? 'active' : ''}`} onClick={() => setMode('move')}>Move</button>
|
||||
<button className={`drawer-tab ${mode === 'copy' ? 'active' : ''}`} onClick={() => setMode('copy')}>Copy</button>
|
||||
</div>
|
||||
|
||||
<div style={{fontSize: 12, color: '#8d9cad', marginBottom: 12}}>
|
||||
Source: <span className="mono" style={{color: 'var(--text-2)'}}>{resource.name}/{srcObject.key}</span>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>Destination {resourceLabel}</label>
|
||||
{resourcesQuery.isLoading ? (
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#8d9cad'}}>
|
||||
<Loader2 size={13}/> Loading {resourceLabel}s…
|
||||
</div>
|
||||
) : (
|
||||
<select className="input" value={destResourceId} onChange={(e) => setDestResourceId(e.target.value)}>
|
||||
{resources.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}{r.id === resource.id ? ' (current)' : ''}
|
||||
</option>
|
||||
))}
|
||||
<option value={NEW_RESOURCE_SENTINEL}>+ New {resourceLabel}…</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCreatingNew && (
|
||||
<div style={{display: 'flex', gap: 6, alignItems: 'center', marginTop: -6, marginBottom: 4}}>
|
||||
<input
|
||||
className="input"
|
||||
autoFocus
|
||||
value={newResourceName}
|
||||
onChange={(e) => setNewResourceName(e.target.value.toLowerCase().replace(cloud === 'azure' ? /[^a-z0-9-]/g : /[^a-z0-9.-]/g, ''))}
|
||||
placeholder={`new-${resourceLabel}-name`}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && newResourceName.trim().length >= 3) createResourceMut.mutate() }}
|
||||
/>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={newResourceName.trim().length < 3 || createResourceMut.isPending}
|
||||
onClick={() => createResourceMut.mutate()}
|
||||
>
|
||||
{createResourceMut.isPending ? <Loader2 size={13}/> : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{createResourceMut.isError && (
|
||||
<p style={{fontSize: 12, color: 'var(--status-error)', margin: '0 0 6px'}}>
|
||||
{createResourceMut.error instanceof Error ? createResourceMut.error.message : 'Create failed'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
<label>Destination key</label>
|
||||
<input className="input" value={destKey} onChange={(e) => setDestKey(e.target.value)}/>
|
||||
</div>
|
||||
|
||||
{error && <p style={{fontSize: 12, color: 'var(--status-error)', margin: '0 0 4px'}}>{error}</p>}
|
||||
|
||||
<div className="copy-modal-footer">
|
||||
<button className="button" onClick={onClose} disabled={isPending}>Cancel</button>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={!destKey.trim() || isCreatingNew || !canCopy || isPending}
|
||||
onClick={() => onConfirm(destKey, resolvedDestId, mode)}
|
||||
>
|
||||
{isPending ? <Loader2 size={13}/> : <Copy size={13}/>}
|
||||
{mode === 'move' ? 'Move' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Breadcrumb ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ObjectBreadcrumb({prefix, onNavigate}: {prefix: string; onNavigate: (prefix: string) => void}) {
|
||||
const segments = prefix ? prefix.replace(/\/$/, '').split('/') : []
|
||||
return (
|
||||
<div className="object-breadcrumb">
|
||||
<button type="button" onClick={() => onNavigate('')}>Root</button>
|
||||
{segments.map((segment, index) => {
|
||||
const path = `${segments.slice(0, index + 1).join('/')}/`
|
||||
return (
|
||||
<span key={path}>
|
||||
<ChevronRight size={11}/>
|
||||
<button type="button" onClick={() => onNavigate(path)}>{segment}</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function normalizePrefix(value: string): string {
|
||||
const trimmed = value.trim().replace(/^\/+/, '')
|
||||
return trimmed && !trimmed.endsWith('/') ? `${trimmed}/` : trimmed
|
||||
}
|
||||
|
||||
function normalizeFolderName(value: string): string {
|
||||
const normalized = value.trim().replace(/^\/+/, '')
|
||||
return normalized.endsWith('/') ? normalized : `${normalized}/`
|
||||
}
|
||||
|
||||
function parentPrefix(prefix: string): string {
|
||||
const segments = prefix.replace(/\/$/, '').split('/').filter(Boolean)
|
||||
segments.pop()
|
||||
return segments.length ? `${segments.join('/')}/` : ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Boxes,
|
||||
Circle,
|
||||
Clock,
|
||||
Container,
|
||||
Database,
|
||||
HardDrive,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Radio,
|
||||
ScrollText,
|
||||
Scale,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Table2,
|
||||
Workflow,
|
||||
Waypoints,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Maps the server's `iconKey` hint to a component.
|
||||
*
|
||||
* Icon keys are additive and never load-bearing: the server can ship a service
|
||||
* whose key this build has never heard of, and the nav must still render. Passing
|
||||
* `undefined` as a JSX component throws and — before the ErrorBoundary lands —
|
||||
* would blank the whole app, so the fallback is not optional.
|
||||
*/
|
||||
const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
storage: HardDrive,
|
||||
database: Table2,
|
||||
nosql: Database,
|
||||
k8s: Boxes,
|
||||
compute: Server,
|
||||
containers: Container,
|
||||
networking: Network,
|
||||
apigateway: Waypoints,
|
||||
serverless: Zap,
|
||||
secrets: KeyRound,
|
||||
iac: Layers,
|
||||
messaging: MessageSquare,
|
||||
events: Radio,
|
||||
email: MessageSquare,
|
||||
queue: MessageSquare,
|
||||
logs: ScrollText,
|
||||
iam: ShieldCheck,
|
||||
kms: Lock,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
configuration: SlidersHorizontal,
|
||||
scheduler: Clock,
|
||||
workflows: Workflow,
|
||||
}
|
||||
|
||||
const FALLBACK_ICON: LucideIcon = Circle
|
||||
|
||||
export function serviceIcon(iconKey?: string): LucideIcon {
|
||||
return (iconKey && SERVICE_ICONS[iconKey]) || FALLBACK_ICON
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {type ElementType} from 'react'
|
||||
import {Cloud, Database, ExternalLink, Radio, Route, ShieldCheck} from 'lucide-react'
|
||||
import {adapterLabel, cloudName, providerDescription, runtimeName} from './cloudConsoleHome.utils'
|
||||
import type {ProviderBannerProps, RuntimeFlowProps, ServiceGridProps, SummarySectionProps} from './types'
|
||||
|
||||
export function ProviderBanner({cloud, runtimeClass, runtimeReachable, onOpenStorage}: ProviderBannerProps) {
|
||||
return (
|
||||
<section className={`console-provider-banner ${runtimeClass}`}>
|
||||
<div>
|
||||
<p className="eyebrow">Selected Cloud</p>
|
||||
<h3>{cloudName(cloud)}</h3>
|
||||
<p>{providerDescription(cloud)}</p>
|
||||
</div>
|
||||
<div className="console-provider-actions">
|
||||
<button className="button primary" type="button" disabled={!runtimeReachable} onClick={onOpenStorage}>
|
||||
<ExternalLink size={14}/>
|
||||
Open Storage
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function SummarySection({
|
||||
cloud,
|
||||
runtimeLabel,
|
||||
runtimeState,
|
||||
runtimeClass,
|
||||
runtimeDetail,
|
||||
activeServices,
|
||||
activeServicesDetail,
|
||||
resourceCount,
|
||||
resourceDetail,
|
||||
}: SummarySectionProps) {
|
||||
return (
|
||||
<section className="console-summary">
|
||||
<SummaryTile label="Cloud" value={cloud.toUpperCase()} detail={runtimeLabel} icon={Cloud}/>
|
||||
<SummaryTile label="Runtime" value={runtimeState} detail={runtimeDetail} icon={Radio} state={runtimeClass}/>
|
||||
<SummaryTile label="Active services" value={`${activeServices}`} detail={activeServicesDetail}/>
|
||||
<SummaryTile label="Resources" value={`${resourceCount}`} detail={resourceDetail}/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function RuntimeFlow({cloud, status}: RuntimeFlowProps) {
|
||||
return (
|
||||
<section className="console-runtime-flow">
|
||||
<FlowStep icon={Cloud} label="UI" value="Console Home"/>
|
||||
<FlowStep icon={Route} label="Proxy" value="/api/clouds"/>
|
||||
<FlowStep icon={ShieldCheck} label="Adapter" value={adapterLabel(cloud, status)}/>
|
||||
<FlowStep icon={Database} label="Runtime" value={runtimeName(cloud)}/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServiceGrid({services, runtimeReachable, onNavigate, searchQuery}: ServiceGridProps) {
|
||||
if (services.length === 0) {
|
||||
return (
|
||||
<section className="console-service-grid empty">
|
||||
<p className="muted" style={{ padding: '24px', textAlign: 'center', gridColumn: '1 / -1' }}>
|
||||
{searchQuery ? `No services matching "${searchQuery}"` : 'No services found.'}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="console-service-grid">
|
||||
{services.map((service) => {
|
||||
const Icon = service.icon
|
||||
const isAvailable = service.status === 'available'
|
||||
const canOpen = isAvailable && service.route && runtimeReachable
|
||||
const content = (
|
||||
<>
|
||||
<div className="service-card-header">
|
||||
<div className="service-icon"><Icon size={18}/></div>
|
||||
<div>
|
||||
<h3>{service.label}</h3>
|
||||
<span className={isAvailable ? 'status healthy' : 'status unknown'}>
|
||||
{isAvailable ? 'available' : 'coming soon'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="console-service-meta">
|
||||
<strong>{service.count ?? '-'}</strong>
|
||||
<span>{service.meta}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return canOpen ? (
|
||||
<button key={service.id} className="service-card console-service-card" type="button" onClick={() => onNavigate(service.route!)}>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div key={service.id} className={`service-card console-service-card ${isAvailable ? 'blocked' : 'offline'}`}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryTile({label, value, detail, icon, state}: {label: string; value: string; detail: string; icon?: ElementType; state?: string}) {
|
||||
const Icon = icon
|
||||
return (
|
||||
<div className={`summary-tile ${state ?? ''}`}>
|
||||
{Icon && <Icon size={16}/>}
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small>{detail}</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowStep({icon, label, value}: {icon: ElementType; label: string; value: string}) {
|
||||
const Icon = icon
|
||||
return (
|
||||
<div className="console-flow-step">
|
||||
<Icon size={16}/>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Re-export shim. These queries moved to api/queries/cloudQueries so the app
|
||||
* shell can use them without importing from a feature folder.
|
||||
*/
|
||||
export {
|
||||
cloudQueryKeys as cloudConsoleHomeQueryKeys,
|
||||
useCloudConsoleResourcesQuery,
|
||||
useCloudServiceStatusQuery,
|
||||
useCloudServicesQuery,
|
||||
useCloudStatusQuery,
|
||||
useCloudsQuery,
|
||||
} from '@/api/queries/cloudQueries'
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {CloudProvider, CloudStatus} from '@/types/cloud'
|
||||
|
||||
export function runtimeEndpointLabel(status?: CloudStatus): string {
|
||||
return status?.endpoint ?? 'Unknown endpoint'
|
||||
}
|
||||
|
||||
export function runtimeLabelFor(status: CloudStatus | undefined, loading: boolean): string {
|
||||
if (loading) return 'Checking runtime'
|
||||
if (!status) return 'Unknown'
|
||||
if (status.runtime === 'reachable') return 'Runtime reachable'
|
||||
if (status.runtime === 'unavailable') return 'Runtime unavailable'
|
||||
return 'Coming soon'
|
||||
}
|
||||
|
||||
export function runtimeClassFor(status: CloudStatus | undefined, loading: boolean): 'ready' | 'pending' | 'unavailable' {
|
||||
if (loading || !status || status.runtime === 'coming_soon') return 'pending'
|
||||
return status.runtime === 'reachable' ? 'ready' : 'unavailable'
|
||||
}
|
||||
|
||||
export function runtimeDetailFor(cloud: CloudProvider, status?: CloudStatus): string {
|
||||
if (status?.runtime === 'reachable') return 'Connected through Cloud Proxy API'
|
||||
if (status?.runtime === 'unavailable') return 'Start the selected runtime to load resources'
|
||||
if (cloud === 'gcp') return 'Waiting for Floci-GCP runtime status'
|
||||
return 'Waiting for runtime status'
|
||||
}
|
||||
|
||||
export function resourceDetailFor(
|
||||
status: CloudStatus | undefined,
|
||||
statusLoading: boolean,
|
||||
resourcesLoading: boolean,
|
||||
resourcesError: boolean,
|
||||
): string {
|
||||
if (statusLoading) return 'Waiting for runtime status'
|
||||
if (status?.runtime === 'unavailable') return 'Blocked until runtime is reachable'
|
||||
if (status?.runtime === 'coming_soon') return 'No adapter registered yet'
|
||||
if (resourcesLoading) return 'Loading normalized resources'
|
||||
if (resourcesError) return 'Resource load failed'
|
||||
return 'Normalized resources across available services'
|
||||
}
|
||||
|
||||
export function serviceMetaLabel(status: CloudStatus | undefined, loading: boolean, label: string): string {
|
||||
if (status?.runtime === 'unavailable') return 'runtime unavailable'
|
||||
if (status?.runtime === 'coming_soon') return 'coming soon'
|
||||
if (loading) return `loading ${label}`
|
||||
return label
|
||||
}
|
||||
|
||||
export function cloudName(cloud: CloudProvider): string {
|
||||
if (cloud === 'aws') return 'AWS Local Runtime'
|
||||
if (cloud === 'azure') return 'Azure Local Runtime'
|
||||
return 'GCP Local Runtime'
|
||||
}
|
||||
|
||||
export function providerDescription(cloud: CloudProvider): string {
|
||||
if (cloud === 'aws') return 'Storage, runtime infrastructure, and AWS-specific pages are backed by Floci AWS Core.'
|
||||
if (cloud === 'azure') return 'Storage is backed by Floci-AZ through the same normalized storage contract.'
|
||||
return 'Storage is backed by Floci-GCP through the same normalized storage contract.'
|
||||
}
|
||||
|
||||
export function adapterLabel(cloud: CloudProvider, status?: CloudStatus): string {
|
||||
if (!status?.adapterRegistered) return 'Coming soon'
|
||||
return `${cloud.toUpperCase()} Adapter`
|
||||
}
|
||||
|
||||
export function runtimeName(cloud: CloudProvider): string {
|
||||
if (cloud === 'aws') return 'Floci AWS Core'
|
||||
if (cloud === 'azure') return 'Floci-AZ'
|
||||
return 'Floci-GCP'
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {ElementType} from 'react'
|
||||
import type {CloudAvailability, CloudProvider, CloudStatus} from '@/types/cloud'
|
||||
|
||||
export interface ConsoleServiceCard {
|
||||
id: string
|
||||
label: string
|
||||
status: CloudAvailability
|
||||
count?: number
|
||||
icon: ElementType
|
||||
route?: string
|
||||
meta: string
|
||||
}
|
||||
|
||||
export interface ProviderBannerProps {
|
||||
cloud: CloudProvider
|
||||
runtimeClass: 'ready' | 'pending' | 'unavailable'
|
||||
runtimeReachable: boolean
|
||||
onOpenStorage: () => void
|
||||
}
|
||||
|
||||
export interface SummarySectionProps {
|
||||
cloud: CloudProvider
|
||||
runtimeLabel: string
|
||||
runtimeState: string
|
||||
runtimeClass: 'ready' | 'pending' | 'unavailable'
|
||||
runtimeDetail: string
|
||||
activeServices: number
|
||||
activeServicesDetail: string
|
||||
resourceCount: number
|
||||
resourceDetail: string
|
||||
}
|
||||
|
||||
export interface ServiceGridProps {
|
||||
services: ConsoleServiceCard[]
|
||||
runtimeReachable: boolean
|
||||
onNavigate: (route: string) => void
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export interface RuntimeFlowProps {
|
||||
cloud: CloudProvider
|
||||
status?: CloudStatus
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {useMemo} from 'react'
|
||||
import {useQueries} from '@tanstack/react-query'
|
||||
import {listCloudResources} from '@/api/cloudProxyClient'
|
||||
import {
|
||||
cloudQueryKeys,
|
||||
useCloudServicesQuery,
|
||||
useCloudStatusQuery,
|
||||
useCloudsQuery,
|
||||
} from '@/api/queries/cloudQueries'
|
||||
import {serviceIcon} from '@/components/serviceIcons'
|
||||
import {
|
||||
resourceDetailFor,
|
||||
runtimeClassFor,
|
||||
runtimeDetailFor,
|
||||
runtimeEndpointLabel,
|
||||
runtimeLabelFor,
|
||||
serviceMetaLabel,
|
||||
} from './cloudConsoleHome.utils'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {ConsoleServiceCard} from './types'
|
||||
|
||||
/**
|
||||
* Console home is driven entirely by `GET /clouds/:cloud/services`.
|
||||
*
|
||||
* It previously hardcoded three resource queries, spliced in a Secrets Manager
|
||||
* card for AWS with a hardcoded "available", and appended two permanent
|
||||
* placeholder cards — so it could disagree with both the sidebar and the API.
|
||||
*/
|
||||
export function useCloudConsoleHomeData(cloud: CloudProvider) {
|
||||
const cloudsQuery = useCloudsQuery()
|
||||
const servicesQuery = useCloudServicesQuery(cloud)
|
||||
const statusQuery = useCloudStatusQuery(cloud)
|
||||
const status = statusQuery.data
|
||||
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data])
|
||||
|
||||
// Availability is derived from adapter registration, so an available service
|
||||
// always has the generic list endpoint. Route shape only decides where the
|
||||
// card links, not whether it can be counted -- a legacy absolute-route page
|
||||
// (AWS Secrets Manager) is still counted through its adapter.
|
||||
const countable = useMemo(
|
||||
() => services.filter((service) => service.availability === 'available'),
|
||||
[services],
|
||||
)
|
||||
|
||||
const runtimeReachable = status?.runtime === 'reachable'
|
||||
const countQueries = useQueries({
|
||||
queries: countable.map((service) => ({
|
||||
queryKey: cloudQueryKeys.resources(cloud, service.service),
|
||||
queryFn: ({signal}: {signal?: AbortSignal}) =>
|
||||
listCloudResources(cloud, service.service, undefined, signal),
|
||||
enabled: runtimeReachable,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
})
|
||||
|
||||
const countsByService = useMemo(() => {
|
||||
const map = new Map<string, {count?: number; isLoading: boolean; isError: boolean}>()
|
||||
countable.forEach((service, index) => {
|
||||
const query = countQueries[index]
|
||||
map.set(service.service, {
|
||||
count: query?.data?.length,
|
||||
isLoading: query?.isLoading ?? false,
|
||||
isError: query?.isError ?? false,
|
||||
})
|
||||
})
|
||||
return map
|
||||
}, [countable, countQueries])
|
||||
|
||||
const serviceCards = useMemo<ConsoleServiceCard[]>(
|
||||
() =>
|
||||
services.map((service): ConsoleServiceCard => {
|
||||
const counts = countsByService.get(service.service)
|
||||
const isLegacyPage = service.route.startsWith('/')
|
||||
return {
|
||||
id: service.service,
|
||||
label: service.displayName,
|
||||
status: service.availability,
|
||||
count: counts?.count,
|
||||
icon: serviceIcon(service.iconKey),
|
||||
route:
|
||||
service.availability === 'available'
|
||||
? isLegacyPage
|
||||
? service.route
|
||||
: `/cloud-explorer/${cloud}/${service.route}`
|
||||
: undefined,
|
||||
meta:
|
||||
service.availability === 'available'
|
||||
? isLegacyPage
|
||||
? 'open service'
|
||||
: serviceMetaLabel(status, counts?.isLoading ?? false, 'resources')
|
||||
: 'coming soon',
|
||||
}
|
||||
}),
|
||||
[cloud, countsByService, services, status],
|
||||
)
|
||||
|
||||
const resourcesLoading = countQueries.some((query) => query.isLoading)
|
||||
const resourcesError = countQueries.some((query) => query.isError)
|
||||
const resourceCount = countQueries.reduce((total, query) => total + (query.data?.length ?? 0), 0)
|
||||
const activeServices = services.filter((service) => service.availability === 'available').length
|
||||
|
||||
return {
|
||||
cloudsQuery,
|
||||
status,
|
||||
runtimeLabel: runtimeEndpointLabel(status),
|
||||
runtimeState: runtimeLabelFor(status, statusQuery.isLoading),
|
||||
runtimeClass: runtimeClassFor(status, statusQuery.isLoading),
|
||||
runtimeDetail: status?.error ?? runtimeDetailFor(cloud, status),
|
||||
activeServices,
|
||||
// Counted rather than asserted: the old copy claimed a fixed service list.
|
||||
activeServicesDetail: servicesQuery.isSuccess
|
||||
? `${activeServices} of ${services.length} services available`
|
||||
: 'Loading services',
|
||||
resourceCount,
|
||||
resourceDetail: resourceDetailFor(status, statusQuery.isLoading, resourcesLoading, resourcesError),
|
||||
serviceCards,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import { Network } from 'lucide-react'
|
||||
import { useAllocateElasticIpMutation } from '@/api/aws/ec2.mutations'
|
||||
import type { Ec2ElasticIp } from '@/api/aws/ec2.api'
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
onAllocated?: (eip: Ec2ElasticIp) => void
|
||||
}
|
||||
|
||||
export function AllocateEipModal({ onClose, onAllocated }: Props) {
|
||||
const [name, setName] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const mutation = useAllocateElasticIpMutation()
|
||||
|
||||
async function handleAllocate() {
|
||||
setErr('')
|
||||
try {
|
||||
const eip = await mutation.mutateAsync(name.trim() || undefined)
|
||||
onAllocated?.(eip)
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="create-table-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Network size={14} /> Allocate Elastic IP
|
||||
</h3>
|
||||
<div className="modal-section">
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 12 }}>
|
||||
Allocates a new VPC-scoped Elastic IP address. Standard hourly charges apply when not associated.
|
||||
</p>
|
||||
<div className="field-row">
|
||||
<label>Name tag <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(optional)</span></label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="my-eip"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{err && <p style={{ color: 'var(--status-error)', fontSize: 12, marginTop: 6 }}>{err}</p>}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="button" onClick={onClose}>Cancel</button>
|
||||
<button className="button primary" disabled={mutation.isPending} onClick={() => void handleAllocate()}>
|
||||
{mutation.isPending ? 'Allocating…' : 'Allocate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
/** Title line, e.g. "Delete VPC" */
|
||||
title: string
|
||||
/** Resource identifier shown in monospace, e.g. "vpc-d0965efd" */
|
||||
resourceId: string
|
||||
/** Human-readable label shown below the ID, e.g. "my-vpc · 10.0.0.0/16 · default" */
|
||||
subtitle?: string
|
||||
/** Extra warning lines shown between resourceId and the input (cascade list, etc.) */
|
||||
warnings?: string[]
|
||||
/** Mutation pending state */
|
||||
isPending?: boolean
|
||||
onConfirm: () => void | Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDeleteModal({ title, resourceId, subtitle, warnings, isPending, onConfirm, onClose }: Props) {
|
||||
const [typed, setTyped] = useState('')
|
||||
const canDelete = typed === 'delete' && !isPending
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!canDelete) return
|
||||
await onConfirm()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="create-table-modal" style={{ width: 420 }} onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="modal-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<AlertTriangle size={14} style={{ color: 'var(--status-error)', flexShrink: 0 }} />
|
||||
<span>{title}</span>
|
||||
<button className="icon-btn" style={{ marginLeft: 'auto' }} onClick={onClose} aria-label="Close" title="Close">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-section">
|
||||
{/* Resource ID + optional subtitle */}
|
||||
<div style={{
|
||||
background: 'var(--bg-secondary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 4,
|
||||
padding: '8px 12px',
|
||||
marginBottom: warnings && warnings.length > 0 ? 10 : 14,
|
||||
}}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 13 }}>{resourceId}</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}>{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cascade / extra warnings */}
|
||||
{warnings && warnings.length > 0 && (
|
||||
<div style={{
|
||||
background: 'rgba(251,191,36,0.08)',
|
||||
border: '1px solid rgba(251,191,36,0.3)',
|
||||
borderRadius: 4,
|
||||
padding: '8px 12px',
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
{warnings.map((w, i) => (
|
||||
<div key={i} style={{ fontSize: 12, color: 'var(--status-warn)', lineHeight: 1.5 }}>
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation input */}
|
||||
<label style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>
|
||||
Type <strong style={{ color: 'var(--text)', fontFamily: 'monospace' }}>delete</strong> to confirm
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="delete"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void handleConfirm() }}
|
||||
autoFocus
|
||||
style={{ borderColor: typed && typed !== 'delete' ? 'var(--status-error)' : undefined }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="button" onClick={onClose} disabled={isPending}>Cancel</button>
|
||||
<button className="button danger" onClick={() => void handleConfirm()} disabled={!canDelete}>
|
||||
{isPending ? <Loader2 size={13} style={{ animation: 'spin 1s linear infinite' }} /> : <Trash2 size={13} />}
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Terminal } from "lucide-react";
|
||||
import { useEc2ConsoleOutputQuery } from "@/api/aws/ec2.queries";
|
||||
|
||||
interface Props {
|
||||
instanceId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConsoleOutputModal({ instanceId, onClose }: Props) {
|
||||
const query = useEc2ConsoleOutputQuery(instanceId);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="create-table-modal" style={{ maxWidth: 720, maxHeight: "85vh", display: "flex", flexDirection: "column" }}>
|
||||
<h3 style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<Terminal size={14} />
|
||||
Console output — {instanceId}
|
||||
</h3>
|
||||
|
||||
{query.isLoading && (
|
||||
<p style={{ fontSize: 12, color: "var(--text-2)" }}>Loading…</p>
|
||||
)}
|
||||
|
||||
{query.isError && (
|
||||
<p style={{ fontSize: 12, color: "var(--status-error)" }}>
|
||||
{query.error instanceof Error ? query.error.message : "Failed to fetch console output."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{query.data && (
|
||||
<>
|
||||
{query.data.timestamp && (
|
||||
<p style={{ fontSize: 11, color: "var(--text-2)", margin: "0 0 8px" }}>
|
||||
As of {new Date(query.data.timestamp).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
<pre
|
||||
className="mono"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
background: "var(--surface-2)",
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{query.data.output || "(no output yet)"}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="modal-footer" style={{ marginTop: 12 }}>
|
||||
<button className="button" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useState } from "react";
|
||||
import { HardDrive, Loader2 } from "lucide-react";
|
||||
import { useCreateAmiMutation } from "@/api/aws/ec2.mutations";
|
||||
|
||||
interface Props {
|
||||
instanceId: string;
|
||||
instanceName: string;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateAmiModal({ instanceId, instanceName, onClose, onCreated }: Props) {
|
||||
const [name, setName] = useState(`${instanceName}-ami`);
|
||||
const [description, setDescription] = useState("");
|
||||
const [noReboot, setNoReboot] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const mutation = useCreateAmiMutation();
|
||||
|
||||
function handleSubmit() {
|
||||
if (!name.trim()) { setErr("Name is required."); return; }
|
||||
setErr("");
|
||||
mutation.mutate(
|
||||
{ instanceId, input: { name: name.trim(), description: description || undefined, noReboot } },
|
||||
{
|
||||
onSuccess: () => { onCreated(); onClose(); },
|
||||
onError: (e) => setErr(e instanceof Error ? e.message : "AMI creation failed."),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="create-table-modal" style={{ maxWidth: 420 }}>
|
||||
<h3>Create AMI</h3>
|
||||
|
||||
<div className="modal-section">
|
||||
<p className="modal-section-title">Image name</p>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: "100%", minWidth: "unset" }}
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Escape" && onClose()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-section">
|
||||
<p className="modal-section-title">Description — optional</p>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: "100%", minWidth: "unset" }}
|
||||
placeholder="e.g. production snapshot"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-section">
|
||||
<div className="field-row" style={{ alignItems: "center" }}>
|
||||
<label className="toggle-switch" style={{ width: 32, height: 18 }}>
|
||||
<input type="checkbox" checked={noReboot} onChange={(e) => setNoReboot(e.target.checked)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
<p className="modal-section-title" style={{ margin: 0 }}>No reboot — skip instance restart before imaging</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && <p style={{ fontSize: 12, color: "var(--status-error)", margin: "0 0 8px" }}>{err}</p>}
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="button" onClick={onClose} disabled={mutation.isPending}>Cancel</button>
|
||||
<button
|
||||
className="button primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim() || mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? <Loader2 size={13} /> : <HardDrive size={13} />}
|
||||
Create AMI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react'
|
||||
import { Cloud } from 'lucide-react'
|
||||
import { useCreateInternetGatewayMutation, useAttachInternetGatewayMutation } from '@/api/aws/ec2.mutations'
|
||||
import { useEc2VpcsQuery } from '@/api/aws/ec2.queries'
|
||||
import type { Ec2InternetGateway } from '@/api/aws/ec2.api'
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
onCreated?: (igw: Ec2InternetGateway) => void
|
||||
}
|
||||
|
||||
export function CreateIgwModal({ onClose, onCreated }: Props) {
|
||||
const [name, setName] = useState('')
|
||||
const [vpcId, setVpcId] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const vpcsQuery = useEc2VpcsQuery(true)
|
||||
const vpcs = vpcsQuery.data ?? []
|
||||
|
||||
const createMut = useCreateInternetGatewayMutation()
|
||||
const attachMut = useAttachInternetGatewayMutation()
|
||||
|
||||
const isPending = createMut.isPending || attachMut.isPending
|
||||
|
||||
async function handleCreate() {
|
||||
setErr('')
|
||||
try {
|
||||
const igw = await createMut.mutateAsync(name.trim() || undefined)
|
||||
if (vpcId) {
|
||||
await attachMut.mutateAsync({ igwId: igw.internetGatewayId, vpcId })
|
||||
}
|
||||
onCreated?.(igw)
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="create-table-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Cloud size={14} /> Create Internet Gateway
|
||||
</h3>
|
||||
<div className="modal-section">
|
||||
<div className="field-row">
|
||||
<label>Name <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(optional)</span></label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="my-igw"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<label>Attach to VPC <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(optional)</span></label>
|
||||
<select className="input" value={vpcId} onChange={(e) => setVpcId(e.target.value)}>
|
||||
<option value="">— skip attach —</option>
|
||||
{vpcs.map((v) => (
|
||||
<option key={v.vpcId} value={v.vpcId}>{v.vpcId} ({v.cidrBlock})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{err && <p style={{ color: 'var(--status-error)', fontSize: 12, marginTop: 6 }}>{err}</p>}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="button" onClick={onClose}>Cancel</button>
|
||||
<button className="button primary" disabled={isPending} onClick={() => void handleCreate()}>
|
||||
{isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from 'react'
|
||||
import { Waypoints } from 'lucide-react'
|
||||
import { useCreateNatGatewayMutation } from '@/api/aws/ec2.mutations'
|
||||
import { useEc2SubnetsQuery, useEc2ElasticIpsQuery } from '@/api/aws/ec2.queries'
|
||||
import type { Ec2NatGateway } from '@/api/aws/ec2.api'
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
onCreated?: (nat: Ec2NatGateway) => void
|
||||
}
|
||||
|
||||
type EipSource = 'auto' | 'existing'
|
||||
|
||||
export function CreateNatGwModal({ onClose, onCreated }: Props) {
|
||||
const [name, setName] = useState('')
|
||||
const [subnetId, setSubnetId] = useState('')
|
||||
const [eipSource, setEipSource] = useState<EipSource>('auto')
|
||||
const [allocationId, setAllocationId] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const subnetsQuery = useEc2SubnetsQuery(undefined, true)
|
||||
const subnets = subnetsQuery.data ?? []
|
||||
|
||||
const eipsQuery = useEc2ElasticIpsQuery(eipSource === 'existing')
|
||||
// Only unattached EIPs can be bound to a NAT GW
|
||||
const freeEips = (eipsQuery.data ?? []).filter((e) => !e.associationId && !e.instanceId)
|
||||
|
||||
const mutation = useCreateNatGatewayMutation()
|
||||
|
||||
async function handleCreate() {
|
||||
if (!subnetId) { setErr('Select a subnet.'); return }
|
||||
if (eipSource === 'existing' && !allocationId) { setErr('Select an Elastic IP.'); return }
|
||||
setErr('')
|
||||
try {
|
||||
const nat = await mutation.mutateAsync({
|
||||
name: name.trim() || undefined,
|
||||
subnetId,
|
||||
allocationId: eipSource === 'existing' ? allocationId : undefined,
|
||||
})
|
||||
onCreated?.(nat)
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="create-table-modal" style={{ width: 520 }} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Waypoints size={14} /> Create NAT Gateway
|
||||
</h3>
|
||||
|
||||
<div className="modal-section">
|
||||
<div className="field-row">
|
||||
<label>Name <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(optional)</span></label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="my-nat-gateway"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field-row">
|
||||
<label>Subnet <span style={{ color: 'var(--status-error)' }}>*</span></label>
|
||||
<select
|
||||
className="input"
|
||||
value={subnetId}
|
||||
onChange={(e) => { setSubnetId(e.target.value); setErr('') }}
|
||||
>
|
||||
<option value="">— select subnet —</option>
|
||||
{subnets.map((s) => (
|
||||
<option key={s.subnetId} value={s.subnetId}>
|
||||
{s.subnetId} — {s.cidrBlock} ({s.availabilityZone})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-section">
|
||||
<p className="modal-section-title">Elastic IP</p>
|
||||
|
||||
<div className="field-row" style={{ alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="eipSource"
|
||||
checked={eipSource === 'auto'}
|
||||
onChange={() => { setEipSource('auto'); setAllocationId('') }}
|
||||
/>
|
||||
Allocate new EIP automatically
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="field-row" style={{ alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="eipSource"
|
||||
checked={eipSource === 'existing'}
|
||||
onChange={() => setEipSource('existing')}
|
||||
/>
|
||||
Use existing EIP
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{eipSource === 'existing' && (
|
||||
<div className="field-row" style={{ marginTop: 4 }}>
|
||||
<label>Allocation</label>
|
||||
{eipsQuery.isLoading ? (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)' }}>Loading…</p>
|
||||
) : freeEips.length === 0 ? (
|
||||
<p style={{ fontSize: 12, color: 'var(--status-error)' }}>No unattached EIPs. Allocate one first.</p>
|
||||
) : (
|
||||
<select
|
||||
className="input"
|
||||
value={allocationId}
|
||||
onChange={(e) => { setAllocationId(e.target.value); setErr('') }}
|
||||
>
|
||||
<option value="">— select EIP —</option>
|
||||
{freeEips.map((e) => (
|
||||
<option key={e.allocationId} value={e.allocationId}>
|
||||
{e.publicIp} ({e.allocationId})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <p style={{ color: 'var(--status-error)', fontSize: 12, marginTop: 6 }}>{err}</p>}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="button" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={!subnetId || mutation.isPending}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{mutation.isPending ? 'Creating… (this may take ~15s)' : 'Create NAT Gateway'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { Router } from 'lucide-react'
|
||||
import { useCreateRouteTableMutation } from '@/api/aws/ec2.mutations'
|
||||
import { useEc2VpcsQuery } from '@/api/aws/ec2.queries'
|
||||
import type { Ec2RouteTable } from '@/api/aws/ec2.api'
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
onCreated?: (rtb: Ec2RouteTable) => void
|
||||
}
|
||||
|
||||
export function CreateRtbModal({ onClose, onCreated }: Props) {
|
||||
const [vpcId, setVpcId] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const vpcsQuery = useEc2VpcsQuery(true)
|
||||
const vpcs = vpcsQuery.data ?? []
|
||||
|
||||
const mutation = useCreateRouteTableMutation()
|
||||
|
||||
async function handleCreate() {
|
||||
if (!vpcId) { setErr('Select a VPC.'); return }
|
||||
setErr('')
|
||||
try {
|
||||
const rtb = await mutation.mutateAsync({ vpcId, name: name.trim() || undefined })
|
||||
onCreated?.(rtb)
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="create-table-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<Router size={14} /> Create Route Table
|
||||
</h3>
|
||||
<div className="modal-section">
|
||||
<div className="field-row">
|
||||
<label>VPC <span style={{ color: 'var(--status-error)' }}>*</span></label>
|
||||
<select
|
||||
className="input"
|
||||
value={vpcId}
|
||||
onChange={(e) => { setVpcId(e.target.value); setErr('') }}
|
||||
>
|
||||
<option value="">— select VPC —</option>
|
||||
{vpcs.map((v) => (
|
||||
<option key={v.vpcId} value={v.vpcId}>{v.vpcId} ({v.cidrBlock})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<label>Name <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>(optional)</span></label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="my-route-table"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{err && <p style={{ color: 'var(--status-error)', fontSize: 12, marginTop: 6 }}>{err}</p>}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="button" onClick={onClose}>Cancel</button>
|
||||
<button className="button primary" disabled={!vpcId || mutation.isPending} onClick={() => void handleCreate()}>
|
||||
{mutation.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
useCreateRouteMutation,
|
||||
useDeleteRouteMutation,
|
||||
useAssociateRouteTableMutation,
|
||||
useDisassociateRouteTableMutation,
|
||||
} from '@/api/aws/ec2.mutations'
|
||||
import { isValidCidr } from './validators'
|
||||
import type { Ec2Route, Ec2RouteTableAssociation, Ec2InternetGateway, Ec2NatGateway, Ec2Subnet } from '@/api/aws/ec2.api'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type TargetKind = 'igw' | 'nat' | 'peering'
|
||||
|
||||
type Props = {
|
||||
rtbId: string
|
||||
routes: Ec2Route[]
|
||||
associations: Ec2RouteTableAssociation[]
|
||||
igws: Ec2InternetGateway[]
|
||||
natGws: Ec2NatGateway[]
|
||||
subnets: Ec2Subnet[]
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function targetLabel(r: Ec2Route): string {
|
||||
if (r.gatewayId === 'local') return 'local'
|
||||
if (r.gatewayId) return r.gatewayId
|
||||
if (r.natGatewayId) return r.natGatewayId
|
||||
if (r.vpcPeeringConnectionId) return r.vpcPeeringConnectionId
|
||||
return '—'
|
||||
}
|
||||
|
||||
function canDeleteRoute(r: Ec2Route): boolean {
|
||||
return r.gatewayId !== 'local' && r.origin !== 'CreateRouteTable'
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function RouteTableEditor({ rtbId, routes, associations, igws, natGws, subnets }: Props) {
|
||||
// New-route form state
|
||||
const [newCidr, setNewCidr] = useState('')
|
||||
const [targetKind, setTargetKind] = useState<TargetKind>('igw')
|
||||
const [targetId, setTargetId] = useState('')
|
||||
const [routeErr, setRouteErr] = useState('')
|
||||
|
||||
// New association form state
|
||||
const [newSubnetId, setNewSubnetId] = useState('')
|
||||
const [assocErr, setAssocErr] = useState('')
|
||||
|
||||
const createRouteMut = useCreateRouteMutation()
|
||||
const deleteRouteMut = useDeleteRouteMutation()
|
||||
const associateMut = useAssociateRouteTableMutation()
|
||||
const disassociateMut = useDisassociateRouteTableMutation()
|
||||
|
||||
// Subnets not yet associated with this RT
|
||||
const associatedSubnetIds = new Set(associations.map((a) => a.subnetId).filter(Boolean))
|
||||
const availableSubnets = subnets.filter((s) => !associatedSubnetIds.has(s.subnetId))
|
||||
|
||||
// Targets by kind
|
||||
const targets: { id: string; label: string }[] =
|
||||
targetKind === 'igw'
|
||||
? igws.map((g) => ({ id: g.internetGatewayId, label: g.internetGatewayId }))
|
||||
: targetKind === 'nat'
|
||||
? natGws.filter((n) => n.state === 'available').map((n) => ({
|
||||
id: n.natGatewayId,
|
||||
label: `${n.natGatewayId} (${n.publicIp ?? ''})`,
|
||||
}))
|
||||
: []
|
||||
|
||||
const cidrValid = isValidCidr(newCidr)
|
||||
const isDuplicateRoute = cidrValid && routes.some((r) => r.destinationCidrBlock === newCidr.trim())
|
||||
const canAddRoute = cidrValid && targetId !== '' && !isDuplicateRoute
|
||||
|
||||
async function handleAddRoute() {
|
||||
if (!canAddRoute) return
|
||||
setRouteErr('')
|
||||
try {
|
||||
await createRouteMut.mutateAsync({
|
||||
rtbId,
|
||||
input: {
|
||||
destinationCidrBlock: newCidr.trim(),
|
||||
gatewayId: targetKind === 'igw' ? targetId : undefined,
|
||||
natGatewayId: targetKind === 'nat' ? targetId : undefined,
|
||||
vpcPeeringConnectionId: targetKind === 'peering' ? targetId : undefined,
|
||||
},
|
||||
})
|
||||
setNewCidr('')
|
||||
setTargetId('')
|
||||
} catch (e) {
|
||||
setRouteErr(e instanceof Error ? e.message : 'Failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRoute(cidr: string) {
|
||||
try {
|
||||
await deleteRouteMut.mutateAsync({ rtbId, cidr })
|
||||
} catch {
|
||||
// non-blocking; RT will refresh on invalidation
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssociate() {
|
||||
if (!newSubnetId) return
|
||||
setAssocErr('')
|
||||
try {
|
||||
await associateMut.mutateAsync({ rtbId, subnetId: newSubnetId })
|
||||
setNewSubnetId('')
|
||||
} catch (e) {
|
||||
setAssocErr(e instanceof Error ? e.message : 'Failed')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisassociate(associationId: string) {
|
||||
try {
|
||||
await disassociateMut.mutateAsync(associationId)
|
||||
} catch {
|
||||
// non-blocking
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{/* ── Routes ──────────────────────────────────────────────────── */}
|
||||
<div className="table-panel">
|
||||
<div className="widget-header">
|
||||
<h3>Routes</h3>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Destination</th>
|
||||
<th>Target</th>
|
||||
<th>Status</th>
|
||||
<th>Origin</th>
|
||||
<th style={{ width: 32 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{routes.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td className="mono" style={{ fontSize: 12 }}>{r.destinationCidrBlock ?? '—'}</td>
|
||||
<td className="mono" style={{ fontSize: 12 }}>{targetLabel(r)}</td>
|
||||
<td>
|
||||
<span className={`status ${r.state === 'active' ? 'healthy' : 'unknown'}`}>
|
||||
{r.state ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: 11, color: 'var(--text-muted)' }}>{r.origin ?? '—'}</td>
|
||||
<td>
|
||||
{canDeleteRoute(r) && (
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="Delete route"
|
||||
disabled={deleteRouteMut.isPending}
|
||||
onClick={() => void handleDeleteRoute(r.destinationCidrBlock ?? '')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* New route row */}
|
||||
<tr>
|
||||
<td>
|
||||
<input
|
||||
className="input"
|
||||
style={{
|
||||
fontSize: 12, padding: '2px 6px',
|
||||
borderColor: newCidr && (!cidrValid || isDuplicateRoute) ? 'var(--status-error)' : undefined,
|
||||
}}
|
||||
placeholder="0.0.0.0/0"
|
||||
value={newCidr}
|
||||
onChange={(e) => { setNewCidr(e.target.value); setRouteErr('') }}
|
||||
/>
|
||||
</td>
|
||||
<td colSpan={3} style={{ display: 'flex', gap: 4, alignItems: 'center', padding: '4px 8px' }}>
|
||||
<select
|
||||
className="input"
|
||||
style={{ fontSize: 12, padding: '2px 6px', flex: '0 0 auto' }}
|
||||
value={targetKind}
|
||||
onChange={(e) => { setTargetKind(e.target.value as TargetKind); setTargetId('') }}
|
||||
>
|
||||
<option value="igw">Internet Gateway</option>
|
||||
<option value="nat">NAT Gateway</option>
|
||||
{/* VPC peering shown only if we add peering support later */}
|
||||
</select>
|
||||
<select
|
||||
className="input"
|
||||
style={{ fontSize: 12, padding: '2px 6px', flex: 1 }}
|
||||
value={targetId}
|
||||
onChange={(e) => setTargetId(e.target.value)}
|
||||
>
|
||||
<option value="">— select target —</option>
|
||||
{targets.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="Add route"
|
||||
disabled={!canAddRoute || createRouteMut.isPending}
|
||||
onClick={() => void handleAddRoute()}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{isDuplicateRoute && <p style={{ color: 'var(--status-error)', fontSize: 11, padding: '4px 8px' }}>Route already exists</p>}
|
||||
{routeErr && <p style={{ color: 'var(--status-error)', fontSize: 11, padding: '4px 8px' }}>{routeErr}</p>}
|
||||
</div>
|
||||
|
||||
{/* ── Subnet Associations ──────────────────────────────────────── */}
|
||||
<div className="table-panel">
|
||||
<div className="widget-header">
|
||||
<h3>Subnet associations</h3>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Association ID</th>
|
||||
<th>Subnet ID</th>
|
||||
<th>Main</th>
|
||||
<th style={{ width: 32 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{associations.map((a) => (
|
||||
<tr key={a.associationId}>
|
||||
<td className="mono" style={{ fontSize: 12 }}>{a.associationId}</td>
|
||||
<td className="mono" style={{ fontSize: 12 }}>{a.subnetId ?? '—'}</td>
|
||||
<td>{a.isMain ? <span className="status healthy">Yes</span> : '—'}</td>
|
||||
<td>
|
||||
{!a.isMain && (
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="Disassociate"
|
||||
disabled={disassociateMut.isPending}
|
||||
onClick={() => void handleDisassociate(a.associationId)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* Associate new subnet */}
|
||||
{availableSubnets.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<select
|
||||
className="input"
|
||||
style={{ fontSize: 12, padding: '2px 6px', width: '100%' }}
|
||||
value={newSubnetId}
|
||||
onChange={(e) => { setNewSubnetId(e.target.value); setAssocErr('') }}
|
||||
>
|
||||
<option value="">— associate a subnet —</option>
|
||||
{availableSubnets.map((s) => (
|
||||
<option key={s.subnetId} value={s.subnetId}>
|
||||
{s.subnetId} — {s.cidrBlock}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="icon-btn"
|
||||
title="Associate"
|
||||
disabled={!newSubnetId || associateMut.isPending}
|
||||
onClick={() => void handleAssociate()}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{assocErr && <p style={{ color: 'var(--status-error)', fontSize: 11, padding: '4px 8px' }}>{assocErr}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { newRule, ruleErrors } from "./sgRuleHelpers";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SgRule = {
|
||||
id: string;
|
||||
type: string;
|
||||
protocol: string;
|
||||
fromPort: string;
|
||||
toPort: string;
|
||||
cidr: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
// ─── Presets ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type RulePreset = {
|
||||
key: string;
|
||||
label: string;
|
||||
protocol: string;
|
||||
fromPort: string;
|
||||
toPort: string;
|
||||
};
|
||||
|
||||
const RULE_PRESETS: RulePreset[] = [
|
||||
{ key: "ssh", label: "SSH", protocol: "tcp", fromPort: "22", toPort: "22" },
|
||||
{ key: "http", label: "HTTP", protocol: "tcp", fromPort: "80", toPort: "80" },
|
||||
{ key: "https", label: "HTTPS", protocol: "tcp", fromPort: "443", toPort: "443" },
|
||||
{ key: "rdp", label: "RDP", protocol: "tcp", fromPort: "3389", toPort: "3389" },
|
||||
{ key: "mysql", label: "MySQL/Aurora", protocol: "tcp", fromPort: "3306", toPort: "3306" },
|
||||
{ key: "postgres", label: "PostgreSQL", protocol: "tcp", fromPort: "5432", toPort: "5432" },
|
||||
{ key: "redis", label: "Redis", protocol: "tcp", fromPort: "6379", toPort: "6379" },
|
||||
{ key: "all-tcp", label: "All TCP", protocol: "tcp", fromPort: "0", toPort: "65535" },
|
||||
{ key: "all-udp", label: "All UDP", protocol: "udp", fromPort: "0", toPort: "65535" },
|
||||
{ key: "all-icmp", label: "All ICMP", protocol: "icmp", fromPort: "-1", toPort: "-1" },
|
||||
{ key: "all", label: "All traffic", protocol: "-1", fromPort: "0", toPort: "0" },
|
||||
{ key: "custom-tcp", label: "Custom TCP", protocol: "tcp", fromPort: "", toPort: "" },
|
||||
{ key: "custom-udp", label: "Custom UDP", protocol: "udp", fromPort: "", toPort: "" },
|
||||
];
|
||||
|
||||
function presetFor(key: string): RulePreset | undefined {
|
||||
return RULE_PRESETS.find((p) => p.key === key);
|
||||
}
|
||||
|
||||
function portRangeDisplay(rule: SgRule): string {
|
||||
if (rule.protocol === "-1") return "All";
|
||||
if (rule.protocol === "icmp") return "All ICMP";
|
||||
if (rule.fromPort === rule.toPort) return rule.fromPort;
|
||||
return `${rule.fromPort} – ${rule.toPort}`;
|
||||
}
|
||||
|
||||
function isCustomType(typeKey: string): boolean {
|
||||
return typeKey === "custom-tcp" || typeKey === "custom-udp";
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SgRuleTableProps {
|
||||
rules: SgRule[];
|
||||
onChange: (rules: SgRule[]) => void;
|
||||
direction: "Inbound" | "Outbound";
|
||||
/** Pass any truthy string to disable add/remove controls (read-only display) */
|
||||
addLabel?: string;
|
||||
}
|
||||
|
||||
export function SgRuleTable({ rules, onChange, direction, addLabel }: SgRuleTableProps) {
|
||||
const readonly = Boolean(addLabel);
|
||||
|
||||
function addRow() {
|
||||
onChange([...rules, newRule()]);
|
||||
}
|
||||
|
||||
function removeRow(id: string) {
|
||||
onChange(rules.filter((r) => r.id !== id));
|
||||
}
|
||||
|
||||
function updateRule(id: string, patch: Partial<SgRule>) {
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
}
|
||||
|
||||
function handleTypeChange(id: string, typeKey: string) {
|
||||
const preset = presetFor(typeKey);
|
||||
if (!preset) return;
|
||||
updateRule(id, {
|
||||
type: typeKey,
|
||||
protocol: preset.protocol,
|
||||
fromPort: preset.fromPort,
|
||||
toPort: preset.toPort,
|
||||
});
|
||||
}
|
||||
|
||||
const sourceLabel = direction === "Inbound" ? "Source" : "Destination";
|
||||
|
||||
return (
|
||||
<div>
|
||||
{rules.length === 0 ? (
|
||||
<div style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 4,
|
||||
padding: "12px 16px",
|
||||
fontSize: 12,
|
||||
color: "var(--text-2)",
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
No {direction.toLowerCase()} rules. Click Add rule to create one.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 4,
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<th style={thStyle}>Type</th>
|
||||
<th style={thStyle}>Protocol</th>
|
||||
<th style={thStyle}>Port range</th>
|
||||
<th style={thStyle}>{sourceLabel}</th>
|
||||
<th style={thStyle}>Description</th>
|
||||
<th style={{ ...thStyle, width: 32 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map((rule, idx) => {
|
||||
const errs = ruleErrors(rule);
|
||||
return (
|
||||
<tr key={rule.id} style={{
|
||||
borderBottom: idx < rules.length - 1 ? "1px solid var(--border-faint)" : "none",
|
||||
}}>
|
||||
{/* Type */}
|
||||
<td style={tdStyle}>
|
||||
{readonly ? (
|
||||
<span style={{ fontSize: 12 }}>{presetFor(rule.type)?.label ?? rule.type}</span>
|
||||
) : (
|
||||
<select
|
||||
className="input"
|
||||
style={{ width: 150, fontSize: 12 }}
|
||||
value={rule.type}
|
||||
onChange={(e) => handleTypeChange(rule.id, e.target.value)}
|
||||
>
|
||||
{RULE_PRESETS.map((p) => (
|
||||
<option key={p.key} value={p.key}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Protocol */}
|
||||
<td style={{ ...tdStyle, color: "var(--text-2)" }}>
|
||||
{rule.protocol === "-1" ? "All" : rule.protocol.toUpperCase()}
|
||||
</td>
|
||||
|
||||
{/* Port range */}
|
||||
<td style={tdStyle}>
|
||||
{rule.protocol === "-1" || rule.protocol === "icmp" || readonly || !isCustomType(rule.type) ? (
|
||||
<span style={{ color: "var(--text-2)", fontSize: 12 }}>{portRangeDisplay(rule)}</span>
|
||||
) : (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<div>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 52, fontSize: 12, minWidth: "unset", borderColor: errs.fromPort ? "var(--status-error)" : undefined }}
|
||||
placeholder="From"
|
||||
value={rule.fromPort}
|
||||
onChange={(e) => updateRule(rule.id, { fromPort: e.target.value })}
|
||||
title={errs.fromPort}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ color: "var(--text-2)" }}>–</span>
|
||||
<div>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 52, fontSize: 12, minWidth: "unset", borderColor: errs.toPort ? "var(--status-error)" : undefined }}
|
||||
placeholder="To"
|
||||
value={rule.toPort}
|
||||
onChange={(e) => updateRule(rule.id, { toPort: e.target.value })}
|
||||
title={errs.toPort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* CIDR */}
|
||||
<td style={tdStyle}>
|
||||
{readonly ? (
|
||||
<span style={{ fontSize: 12 }}>{rule.cidr}</span>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 150, fontSize: 12, minWidth: "unset", borderColor: errs.cidr ? "var(--status-error)" : undefined }}
|
||||
placeholder="0.0.0.0/0"
|
||||
value={rule.cidr}
|
||||
onChange={(e) => updateRule(rule.id, { cidr: e.target.value })}
|
||||
title={errs.cidr}
|
||||
/>
|
||||
{errs.cidr && (
|
||||
<p style={{ fontSize: 11, color: "var(--status-error)", margin: "2px 0 0", whiteSpace: "nowrap" }}>{errs.cidr}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Description */}
|
||||
<td style={tdStyle}>
|
||||
{readonly ? (
|
||||
<span style={{ fontSize: 12, color: "var(--text-2)" }}>{rule.description}</span>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 150, fontSize: 12, minWidth: "unset" }}
|
||||
placeholder="optional"
|
||||
value={rule.description}
|
||||
onChange={(e) => updateRule(rule.id, { description: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Delete */}
|
||||
<td style={{ ...tdStyle, textAlign: "center" }}>
|
||||
{!readonly && (
|
||||
<button className="icon-btn danger" type="button" onClick={() => removeRow(rule.id)}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readonly && (
|
||||
<button className="button" type="button" onClick={addRow} style={{ fontSize: 12 }}>
|
||||
+ Add rule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cell styles ──────────────────────────────────────────────────────────────
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: "8px 10px",
|
||||
textAlign: "left",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-2)",
|
||||
background: "var(--surface-2)",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: "6px 10px",
|
||||
verticalAlign: "middle",
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useUpdateEc2InstanceTagsMutation } from "@/api/aws/ec2.mutations";
|
||||
import type { Ec2Tag } from "@/api/aws/ec2.api";
|
||||
|
||||
interface Props {
|
||||
instanceId: string;
|
||||
initialTags: Ec2Tag[];
|
||||
}
|
||||
|
||||
export function TagEditor({ instanceId, initialTags }: Props) {
|
||||
const [rows, setRows] = useState<Ec2Tag[]>(initialTags);
|
||||
const [err, setErr] = useState("");
|
||||
const mutation = useUpdateEc2InstanceTagsMutation();
|
||||
|
||||
useEffect(() => {
|
||||
setRows(initialTags);
|
||||
}, [initialTags]);
|
||||
|
||||
function addRow() {
|
||||
setRows((prev) => [...prev, { key: "", value: "" }]);
|
||||
}
|
||||
|
||||
function removeRow(idx: number) {
|
||||
setRows((prev) => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function setKey(idx: number, key: string) {
|
||||
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, key } : r)));
|
||||
}
|
||||
|
||||
function setValue(idx: number, value: string) {
|
||||
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, value } : r)));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const newTags = rows.filter((r) => r.key.trim());
|
||||
const newKeys = new Set(newTags.map((t) => t.key));
|
||||
const oldKeys = new Set(initialTags.map((t) => t.key));
|
||||
const toAdd = newTags.filter((t) => !oldKeys.has(t.key) || initialTags.find((ot) => ot.key === t.key)?.value !== t.value);
|
||||
const toRemove = initialTags.map((t) => t.key).filter((k) => !newKeys.has(k));
|
||||
setErr("");
|
||||
mutation.mutate(
|
||||
{ instanceId, toAdd, toRemove },
|
||||
{ onError: (e) => setErr(e instanceof Error ? e.message : "Save failed.") },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, marginBottom: 8 }}>
|
||||
{rows.map((tag, idx) => (
|
||||
<div key={idx} className="field-row" style={{ gap: 4 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Key"
|
||||
value={tag.key}
|
||||
onChange={(e) => setKey(idx, e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Value"
|
||||
value={tag.value}
|
||||
onChange={(e) => setValue(idx, e.target.value)}
|
||||
style={{ flex: 2 }}
|
||||
/>
|
||||
<button className="icon-btn danger" onClick={() => removeRow(idx)} title="Remove">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{err && <p style={{ fontSize: 11, color: "var(--status-error)", margin: "0 0 6px" }}>{err}</p>}
|
||||
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<button className="button compact" onClick={addRow}>
|
||||
<Plus size={12} />
|
||||
Add tag
|
||||
</button>
|
||||
<button className="button compact primary" onClick={handleSave} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? <Loader2 size={12} /> : <Save size={12} />}
|
||||
Save tags
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Network, CheckCircle2, Loader2, AlertCircle, X, Plus, Trash2 } from 'lucide-react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { createVpcWizard } from '@/api/aws/ec2.api'
|
||||
import type { SubnetGroup, VpcWizardInput, VpcWizardResult } from '@/api/aws/ec2.api'
|
||||
import { calculateSubnetCidrs } from '@/lib/network'
|
||||
import { isValidCidr } from './validators'
|
||||
import { ec2QueryKeys } from '@/api/aws/ec2.queries'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Props = {
|
||||
onClose: () => void
|
||||
onCreated?: (result: VpcWizardResult) => void
|
||||
}
|
||||
|
||||
type Step = 'form' | 'creating' | 'done' | 'error'
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const AZ_OPTIONS = ['(auto)', 'us-east-1a', 'us-east-1b', 'us-east-1c', 'us-west-2a', 'us-west-2b']
|
||||
const AZ_LABELS: Record<string, string> = {
|
||||
'(auto)': 'auto', 'us-east-1a': '1a', 'us-east-1b': '1b', 'us-east-1c': '1c',
|
||||
'us-west-2a': '2a', 'us-west-2b': '2b',
|
||||
}
|
||||
|
||||
const DEFAULT_GROUPS: SubnetGroup[] = [
|
||||
{ name: 'public', count: 2, isPublic: true },
|
||||
{ name: 'private', count: 2, isPublic: false },
|
||||
]
|
||||
|
||||
// Muted palette for private groups so they're visually distinct from each other
|
||||
const PRIVATE_COLORS = ['#94a3b8', '#a78bfa', '#fb923c', '#f472b6']
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ResourceRow({ label, id }: { label: string; id: string }) {
|
||||
return (
|
||||
<tr>
|
||||
<td style={{ color: 'var(--text-muted)', paddingRight: 16, whiteSpace: 'nowrap', fontSize: 12 }}>
|
||||
{label}
|
||||
</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{id}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function VpcWizardModal({ onClose, onCreated }: Props) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('')
|
||||
const [cidrBlock, setCidrBlock] = useState('10.0.0.0/16')
|
||||
const [groups, setGroups] = useState<SubnetGroup[]>(DEFAULT_GROUPS)
|
||||
// Status
|
||||
const [step, setStep] = useState<Step>('form')
|
||||
const [result, setResult] = useState<VpcWizardResult | null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
// Derived
|
||||
const cidrValid = isValidCidr(cidrBlock)
|
||||
const totalSubnets = groups.reduce((s, g) => s + g.count, 0)
|
||||
|
||||
const preview = useMemo(
|
||||
() => (cidrValid && totalSubnets > 0 ? calculateSubnetCidrs(cidrBlock, groups) : null),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[cidrBlock, cidrValid, JSON.stringify(groups)],
|
||||
)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (input: VpcWizardInput) => createVpcWizard(input),
|
||||
onSuccess: (data) => {
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.vpcs })
|
||||
void qc.invalidateQueries({ queryKey: ['ec2', 'subnets'] })
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.internetGateways })
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.natGateways() })
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.routeTables() })
|
||||
void qc.invalidateQueries({ queryKey: ec2QueryKeys.elasticIps })
|
||||
setResult(data)
|
||||
setStep('done')
|
||||
onCreated?.(data)
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setErrorMsg(err.message)
|
||||
setStep('error')
|
||||
},
|
||||
})
|
||||
|
||||
function handleCreate() {
|
||||
if (!cidrValid || !name.trim() || groups.length === 0) return
|
||||
setStep('creating')
|
||||
mutation.mutate({
|
||||
name: name.trim(),
|
||||
cidrBlock,
|
||||
subnetGroups: groups,
|
||||
natGateway: false,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Group list helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function updateGroup(idx: number, patch: Partial<SubnetGroup>) {
|
||||
setGroups((prev) => prev.map((g, i) => (i === idx ? { ...g, ...patch } : g)))
|
||||
}
|
||||
|
||||
function addGroup() {
|
||||
setGroups((prev) => [...prev, { name: '', count: 1, isPublic: false }])
|
||||
}
|
||||
|
||||
function removeGroup(idx: number) {
|
||||
setGroups((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const canSubmit = cidrValid && name.trim().length > 0 && groups.length > 0 &&
|
||||
groups.every((g) => g.name.trim().length > 0 && g.count >= 1)
|
||||
|
||||
// ── Private-group color assignment ─────────────────────────────────────────
|
||||
// Track a stable color per private group by their declaration order
|
||||
const privateColors = useMemo(() => {
|
||||
let pi = 0
|
||||
return groups.map((g) => g.isPublic ? '#34d399' : PRIVATE_COLORS[pi++ % PRIVATE_COLORS.length])
|
||||
}, [groups])
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="create-table-modal"
|
||||
style={{ width: 860, maxWidth: '96vw' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="modal-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Network size={15} style={{ color: 'var(--accent)' }} />
|
||||
<span>VPC Wizard</span>
|
||||
<button className="icon-btn" style={{ marginLeft: 'auto' }} onClick={onClose} aria-label="Close" title="Close">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Form ──────────────────────────────────────────────────────── */}
|
||||
{step === 'form' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 0 }}>
|
||||
{/* Left panel — inputs */}
|
||||
<div style={{ flex: '0 0 380px', padding: '16px 20px', borderRight: '1px solid var(--border)', overflowY: 'auto', maxHeight: '70vh' }}>
|
||||
|
||||
<div className="modal-section">
|
||||
<div className="section-title">Identity</div>
|
||||
<div className="field-row">
|
||||
<label>Name</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="my-vpc"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<label>IPv4 CIDR block</label>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="10.0.0.0/16"
|
||||
value={cidrBlock}
|
||||
onChange={(e) => setCidrBlock(e.target.value)}
|
||||
style={{ borderColor: cidrBlock && !cidrValid ? 'var(--status-error)' : undefined }}
|
||||
/>
|
||||
{cidrBlock && !cidrValid && (
|
||||
<span style={{ color: 'var(--status-error)', fontSize: 11, marginTop: 2 }}>
|
||||
Invalid CIDR
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div className="section-title" style={{ margin: 0 }}>Subnet groups</div>
|
||||
<button
|
||||
className="button compact"
|
||||
style={{ marginLeft: 'auto', fontSize: 11 }}
|
||||
onClick={addGroup}
|
||||
>
|
||||
<Plus size={11} /> Add group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||||
No groups defined. Add at least one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{groups.length > 0 && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) 44px 80px 54px 52px auto',
|
||||
gap: 4,
|
||||
padding: '0 6px',
|
||||
marginBottom: 2,
|
||||
}}>
|
||||
{['Name', '#', 'Type', 'Mask', 'AZ', ''].map((h) => (
|
||||
<span key={h} style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{h}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((g, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(60px, 1fr) 38px 70px 46px 44px auto',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
background: 'var(--bg-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 5,
|
||||
padding: '5px 6px',
|
||||
}}
|
||||
>
|
||||
{/* Name */}
|
||||
<input
|
||||
style={{
|
||||
minWidth: 0, fontSize: 12, padding: '3px 7px',
|
||||
background: 'transparent', border: 'none', outline: 'none',
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
placeholder="group name"
|
||||
value={g.name}
|
||||
onChange={(e) => updateGroup(idx, { name: e.target.value })}
|
||||
/>
|
||||
{/* Count */}
|
||||
<select
|
||||
className="input"
|
||||
style={{ minWidth: 0, width: '100%', fontSize: 12, padding: '2px 4px', height: 26 }}
|
||||
value={g.count}
|
||||
onChange={(e) => updateGroup(idx, { count: Number(e.target.value) })}
|
||||
>
|
||||
{[1, 2, 3, 4].map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
{/* Public / Private toggle */}
|
||||
<select
|
||||
className="input"
|
||||
style={{
|
||||
minWidth: 0, width: '100%', fontSize: 11, padding: '2px 4px', height: 26,
|
||||
color: g.isPublic ? '#34d399' : 'var(--text-muted)',
|
||||
}}
|
||||
value={g.isPublic ? 'public' : 'private'}
|
||||
onChange={(e) => updateGroup(idx, { isPublic: e.target.value === 'public' })}
|
||||
>
|
||||
<option value="public">Public</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
{/* Prefix / mask */}
|
||||
<select
|
||||
className="input"
|
||||
style={{ minWidth: 0, width: '100%', fontSize: 11, padding: '2px 2px', height: 26 }}
|
||||
value={g.prefix ?? 'auto'}
|
||||
onChange={(e) => updateGroup(idx, { prefix: e.target.value === 'auto' ? undefined : Number(e.target.value) })}
|
||||
title="Subnet prefix length"
|
||||
>
|
||||
<option value="auto">auto</option>
|
||||
{[20, 21, 22, 23, 24, 25, 26, 27, 28].map((p) => (
|
||||
<option key={p} value={p}>/{p}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* AZ */}
|
||||
<select
|
||||
className="input"
|
||||
style={{ minWidth: 0, width: '100%', fontSize: 11, padding: '2px 2px', height: 26 }}
|
||||
value={g.az ?? '(auto)'}
|
||||
onChange={(e) => updateGroup(idx, { az: e.target.value === '(auto)' ? undefined : e.target.value })}
|
||||
title="Availability zone"
|
||||
>
|
||||
{AZ_OPTIONS.map((a) => (
|
||||
<option key={a} value={a}>{AZ_LABELS[a]}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Remove */}
|
||||
<button
|
||||
className="icon-btn danger"
|
||||
onClick={() => removeGroup(idx)}
|
||||
title="Remove group"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Validation hints */}
|
||||
{groups.some((g) => !g.name.trim()) && (
|
||||
<p style={{ fontSize: 11, color: 'var(--status-error)', marginTop: 4 }}>
|
||||
All groups need a name.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right panel — preview */}
|
||||
<div style={{ flex: 1, padding: '16px 20px', background: 'var(--bg-subtle, var(--bg-secondary))', overflowY: 'auto', maxHeight: '70vh' }}>
|
||||
<div className="section-title" style={{ marginBottom: 12 }}>Preview</div>
|
||||
|
||||
{/* VPC block */}
|
||||
<div style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 6,
|
||||
background: 'var(--bg-primary)',
|
||||
padding: '10px 14px',
|
||||
marginBottom: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent)', marginBottom: 4 }}>VPC</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 12 }}>{cidrValid ? cidrBlock : '—'}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
+ Internet Gateway
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subnet group columns */}
|
||||
{preview && preview.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{preview.map((grp, idx) => {
|
||||
const color = privateColors[idx]
|
||||
const borderColor = `${color}60`
|
||||
const bgColor = `${color}10`
|
||||
return (
|
||||
<div key={idx} style={{ minWidth: 100, flex: '1 1 100px' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color, marginBottom: 6 }}>
|
||||
{grp.name || <em>unnamed</em>} ({grp.cidrs.length})
|
||||
<span style={{ fontWeight: 400, marginLeft: 4, opacity: 0.7 }}>
|
||||
{grp.isPublic ? '· public' : '· private'}
|
||||
</span>
|
||||
</div>
|
||||
{grp.cidrs.map((cidr, i) => (
|
||||
<div key={i} style={{
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderRadius: 4,
|
||||
padding: '5px 9px',
|
||||
marginBottom: 5,
|
||||
background: bgColor,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
}}>
|
||||
{cidr}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>
|
||||
{cidrValid ? 'Add subnet groups to preview.' : 'Enter a valid CIDR to preview.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="button" onClick={onClose}>Cancel</button>
|
||||
<button className="button primary" disabled={!canSubmit} onClick={handleCreate}>
|
||||
Create VPC
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Creating ─────────────────────────────────────────────────── */}
|
||||
{step === 'creating' && (
|
||||
<div style={{ padding: 48, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 14 }}>
|
||||
<Loader2 size={28} style={{ animation: 'spin 1s linear infinite', color: 'var(--accent)' }} />
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>Creating VPC infrastructure…</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
Provisioning VPC, Internet Gateway, {totalSubnets} subnet{totalSubnets !== 1 ? 's' : ''}, route tables…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Done ─────────────────────────────────────────────────────── */}
|
||||
{step === 'done' && result && (
|
||||
<>
|
||||
<div style={{ padding: '24px 28px', maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 20 }}>
|
||||
<CheckCircle2 size={18} style={{ color: '#34d399' }} />
|
||||
<span style={{ fontWeight: 600 }}>VPC created successfully</span>
|
||||
</div>
|
||||
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||
<tbody>
|
||||
<ResourceRow label="VPC" id={result.vpcId} />
|
||||
<ResourceRow label="Internet Gateway" id={result.igwId} />
|
||||
<ResourceRow label="Public Route Table" id={result.publicRouteTableId} />
|
||||
{result.privateRouteTableId && (
|
||||
<ResourceRow label="Private Route Table" id={result.privateRouteTableId} />
|
||||
)}
|
||||
{result.natGatewayId && (
|
||||
<ResourceRow label="NAT Gateway" id={result.natGatewayId} />
|
||||
)}
|
||||
{result.eipAllocationId && (
|
||||
<ResourceRow label="Elastic IP" id={result.eipAllocationId} />
|
||||
)}
|
||||
{result.subnetGroups.map((grp) =>
|
||||
grp.subnetIds.map((id, i) => (
|
||||
<ResourceRow
|
||||
key={id}
|
||||
label={`${grp.name} ${i + 1}`}
|
||||
id={id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="button primary" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Error ────────────────────────────────────────────────────── */}
|
||||
{step === 'error' && (
|
||||
<>
|
||||
<div style={{ padding: '24px 28px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<AlertCircle size={18} style={{ color: 'var(--status-error)' }} />
|
||||
<span style={{ fontWeight: 600 }}>Creation failed</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--status-error)', background: 'var(--bg-secondary)', padding: 12, borderRadius: 4 }}>
|
||||
{errorMsg}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="button" onClick={() => setStep('form')}>Back</button>
|
||||
<button className="button" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { isValidCidr, isValidPort } from "@/lib/network";
|
||||
import type { SgRule } from "./SgRuleTable";
|
||||
|
||||
export function newRule(): SgRule {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: "custom-tcp",
|
||||
protocol: "tcp",
|
||||
fromPort: "",
|
||||
toPort: "",
|
||||
cidr: "0.0.0.0/0",
|
||||
description: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function ruleToPermission(r: SgRule) {
|
||||
return {
|
||||
protocol: r.protocol,
|
||||
fromPort: r.protocol === "-1" ? 0 : (parseInt(r.fromPort) || 0),
|
||||
toPort: r.protocol === "-1" ? 0 : (parseInt(r.toPort) || 0),
|
||||
cidr: r.cidr,
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns per-field errors for a rule: { cidr?, fromPort?, toPort? } */
|
||||
export function ruleErrors(rule: SgRule): { cidr?: string; fromPort?: string; toPort?: string } {
|
||||
const errs: { cidr?: string; fromPort?: string; toPort?: string } = {};
|
||||
if (rule.cidr && !isValidCidr(rule.cidr)) errs.cidr = "Invalid CIDR";
|
||||
if (rule.protocol !== "-1" && rule.protocol !== "icmp") {
|
||||
if (rule.fromPort !== "" && !isValidPort(rule.fromPort)) errs.fromPort = "0–65535";
|
||||
if (rule.toPort !== "" && !isValidPort(rule.toPort)) errs.toPort = "0–65535";
|
||||
if (!errs.fromPort && !errs.toPort && rule.fromPort !== "" && rule.toPort !== "") {
|
||||
if (Number(rule.fromPort) > Number(rule.toPort))
|
||||
errs.fromPort = `From > To`;
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
/** True when every rule in the table has valid CIDR and ports. */
|
||||
export function allRulesValid(rules: SgRule[]): boolean {
|
||||
return rules.every((r) => {
|
||||
const e = ruleErrors(r);
|
||||
return !e.cidr && !e.fromPort && !e.toPort;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* EC2 / AWS-specific validators.
|
||||
* Network-level primitives (CIDR, ports) are imported from src/lib/network.ts.
|
||||
*/
|
||||
import { isValidCidr, isValidPort } from '@/lib/network'
|
||||
|
||||
export { isValidCidr, isCidrWithinBlock, isValidPort, isValidPortRange } from '@/lib/network'
|
||||
|
||||
/**
|
||||
* Returns a human-readable error for an SG inbound/outbound rule, or null when valid.
|
||||
*/
|
||||
export function validateSgRule(
|
||||
protocol: string,
|
||||
fromPort: string,
|
||||
toPort: string,
|
||||
cidr: string,
|
||||
): string | null {
|
||||
if (!cidr.trim()) return 'Source/destination CIDR is required.'
|
||||
if (!isValidCidr(cidr.trim()))
|
||||
return `"${cidr.trim()}" is not a valid CIDR (e.g. 10.0.0.0/16 or 0.0.0.0/0).`
|
||||
if (protocol !== '-1' && protocol !== 'icmp') {
|
||||
if (!isValidPort(fromPort))
|
||||
return `From port "${fromPort}" must be an integer 0–65535.`
|
||||
if (!isValidPort(toPort))
|
||||
return `To port "${toPort}" must be an integer 0–65535.`
|
||||
if (Number(fromPort) > Number(toPort))
|
||||
return `From port (${fromPort}) cannot be greater than To port (${toPort}).`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** True when a key pair name matches AWS constraints (1–255 chars, alphanumeric/hyphens/underscores/dots). */
|
||||
export function isValidKeyPairName(name: string): boolean {
|
||||
return /^[a-zA-Z0-9._-]{1,255}$/.test(name.trim())
|
||||
}
|
||||
|
||||
/** True when an AMI ID matches the expected AWS pattern (ami- + 8 or 17 hex chars). */
|
||||
export function looksLikeAmiId(id: string): boolean {
|
||||
return /^ami-[0-9a-f]{8}([0-9a-f]{9})?$/.test(id.trim())
|
||||
}
|
||||
|
||||
/** True when `size` is a valid EBS volume size in GiB (1–16384). */
|
||||
export function isValidVolumeSize(size: number): boolean {
|
||||
return Number.isInteger(size) && size >= 1 && size <= 16384
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Boxes,
|
||||
Info,
|
||||
RefreshCw,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Tags,
|
||||
} from "lucide-react";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import type { EksCluster, EksNodegroup } from "@/api/aws/eks.api";
|
||||
import {
|
||||
useEksClustersQuery,
|
||||
useEksNodegroupsQuery,
|
||||
} from "@/api/aws/eks.queries";
|
||||
|
||||
function statusClass(status?: string) {
|
||||
const normalized = status?.toLowerCase();
|
||||
if (normalized === "active") return "healthy";
|
||||
if (normalized === "creating" || normalized === "updating") return "degraded";
|
||||
if (normalized === "failed" || normalized === "deleting") return "unavailable";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function yesNo(value?: boolean) {
|
||||
if (value === undefined) return "—";
|
||||
return value ? "Enabled" : "Disabled";
|
||||
}
|
||||
|
||||
function ClusterListItem({
|
||||
cluster,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
cluster: EksCluster;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`list-item ${active ? "active" : ""}`}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
>
|
||||
<strong>{cluster.name}</strong>
|
||||
<span>
|
||||
Kubernetes {cluster.version ?? "—"} · {cluster.nodegroupCount ?? 0} nodegroups
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ClusterSummary({ cluster }: { cluster: EksCluster }) {
|
||||
const vpc = cluster.resourcesVpcConfig;
|
||||
|
||||
return (
|
||||
<div className="grid two">
|
||||
<div className="widget">
|
||||
<div className="widget-header">
|
||||
<Boxes size={13} color="var(--accent)" />
|
||||
<h3>Cluster</h3>
|
||||
</div>
|
||||
<div className="widget-body">
|
||||
<div className="meta-grid">
|
||||
<Meta label="Status" value={cluster.status ?? "unknown"} />
|
||||
<Meta label="Kubernetes version" value={cluster.version ?? "—"} />
|
||||
<Meta label="Platform version" value={cluster.platformVersion ?? "—"} />
|
||||
<Meta label="Created" value={formatDate(cluster.createdAt)} />
|
||||
<Meta label="Endpoint" value={cluster.endpoint ?? "—"} />
|
||||
<Meta label="Role ARN" value={cluster.roleArn ?? "—"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget">
|
||||
<div className="widget-header">
|
||||
<ShieldCheck size={13} color="var(--accent)" />
|
||||
<h3>Networking</h3>
|
||||
</div>
|
||||
<div className="widget-body">
|
||||
<div className="meta-grid">
|
||||
<Meta label="VPC" value={vpc?.vpcId ?? "—"} />
|
||||
<Meta label="Public endpoint" value={yesNo(vpc?.endpointPublicAccess)} />
|
||||
<Meta label="Private endpoint" value={yesNo(vpc?.endpointPrivateAccess)} />
|
||||
<Meta label="Subnets" value={String(vpc?.subnetIds.length ?? 0)} />
|
||||
<Meta
|
||||
label="Security groups"
|
||||
value={String(vpc?.securityGroupIds.length ?? 0)}
|
||||
/>
|
||||
<Meta
|
||||
label="Public CIDRs"
|
||||
value={vpc?.publicAccessCidrs.join(", ") || "—"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClusterTags({ tags }: { tags: Record<string, string> }) {
|
||||
const entries = Object.entries(tags);
|
||||
|
||||
return (
|
||||
<div className="widget section-space">
|
||||
<div className="widget-header">
|
||||
<Tags size={13} color="var(--accent)" />
|
||||
<h3>Tags</h3>
|
||||
<span className="badge neutral">{entries.length}</span>
|
||||
</div>
|
||||
<div className="widget-body">
|
||||
{entries.length === 0 ? (
|
||||
<p className="muted compact-text">No tags returned for this cluster.</p>
|
||||
) : (
|
||||
<div className="metadata-tags">
|
||||
{entries.map(([key, value]) => (
|
||||
<span className="metadata-tag" key={`${key}:${value}`}>
|
||||
<strong>{key}</strong>
|
||||
<span>{value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Meta({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">{label}</span>
|
||||
<span className="meta-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodegroupTable({ nodegroups }: { nodegroups: EksNodegroup[] }) {
|
||||
if (nodegroups.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Server}
|
||||
title="No nodegroups"
|
||||
description="This cluster did not return any managed nodegroups."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Capacity</th>
|
||||
<th>Instances</th>
|
||||
<th>Scaling</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodegroups.map((nodegroup) => (
|
||||
<tr key={nodegroup.arn ?? nodegroup.name}>
|
||||
<td className="mono">{nodegroup.name}</td>
|
||||
<td>
|
||||
<span className={`status ${statusClass(nodegroup.status)}`}>
|
||||
{nodegroup.status ?? "unknown"}
|
||||
</span>
|
||||
</td>
|
||||
<td>{nodegroup.capacityType ?? "—"}</td>
|
||||
<td className="mono">{nodegroup.instanceTypes.join(", ") || "—"}</td>
|
||||
<td className="mono">
|
||||
{nodegroup.scalingConfig
|
||||
? `${nodegroup.scalingConfig.minSize ?? "?"}/${nodegroup.scalingConfig.desiredSize ?? "?"}/${nodegroup.scalingConfig.maxSize ?? "?"}`
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{formatDate(nodegroup.modifiedAt ?? nodegroup.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function EKSPage() {
|
||||
const clustersQuery = useEksClustersQuery();
|
||||
const clusters = useMemo(() => clustersQuery.data ?? [], [clustersQuery.data]);
|
||||
const [selectedClusterName, setSelectedClusterName] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedClusterName && clusters[0]) {
|
||||
setSelectedClusterName(clusters[0].name);
|
||||
}
|
||||
}, [clusters, selectedClusterName]);
|
||||
|
||||
const selectedCluster = useMemo(
|
||||
() => clusters.find((cluster) => cluster.name === selectedClusterName) ?? null,
|
||||
[clusters, selectedClusterName],
|
||||
);
|
||||
const nodegroupsQuery = useEksNodegroupsQuery(selectedClusterName);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>EKS</h1>
|
||||
<span className="info-link">
|
||||
<Info size={11} />
|
||||
Kubernetes clusters
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
void clustersQuery.refetch();
|
||||
void nodegroupsQuery.refetch();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="split">
|
||||
<aside className="list-pane">
|
||||
<div className="widget-header">
|
||||
<Boxes size={13} color="var(--text-2)" />
|
||||
<h3>Clusters ({clusters.length})</h3>
|
||||
</div>
|
||||
|
||||
{clustersQuery.isLoading ? (
|
||||
<div className="empty compact"><p>Loading clusters...</p></div>
|
||||
) : clustersQuery.isError ? (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="Cannot load clusters"
|
||||
description="EKS did not respond from the Floci endpoint."
|
||||
/>
|
||||
) : clusters.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="No EKS clusters"
|
||||
description="No Kubernetes clusters were returned by Floci."
|
||||
/>
|
||||
) : (
|
||||
clusters.map((cluster) => (
|
||||
<ClusterListItem
|
||||
key={cluster.arn ?? cluster.name}
|
||||
cluster={cluster}
|
||||
active={selectedClusterName === cluster.name}
|
||||
onSelect={() => setSelectedClusterName(cluster.name)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="detail-pane">
|
||||
{!selectedCluster ? (
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="Select a cluster"
|
||||
description="Choose an EKS cluster to inspect its networking and nodegroups."
|
||||
/>
|
||||
) : (
|
||||
<div className="content">
|
||||
<div className="page-title" style={{ marginBottom: 16 }}>
|
||||
<Boxes size={18} color="var(--accent)" />
|
||||
<h2>{selectedCluster.name}</h2>
|
||||
<span className={`status ${statusClass(selectedCluster.status)}`}>
|
||||
{selectedCluster.status ?? "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ClusterSummary cluster={selectedCluster} />
|
||||
<ClusterTags tags={selectedCluster.tags} />
|
||||
|
||||
<div className="table-panel section-space">
|
||||
<div className="widget-header">
|
||||
<Server size={13} color="var(--text-2)" />
|
||||
<h3>Managed nodegroups</h3>
|
||||
</div>
|
||||
|
||||
{nodegroupsQuery.isLoading ? (
|
||||
<div className="empty compact"><p>Loading nodegroups...</p></div>
|
||||
) : nodegroupsQuery.isError ? (
|
||||
<EmptyState
|
||||
icon={Server}
|
||||
title="Cannot load nodegroups"
|
||||
description="EKS did not return nodegroup details for this cluster."
|
||||
/>
|
||||
) : (
|
||||
<NodegroupTable nodegroups={nodegroupsQuery.data ?? []} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useState} from 'react'
|
||||
import {Plus, Trash2} from 'lucide-react'
|
||||
import {
|
||||
createKubernetesFargateProfile,
|
||||
createKubernetesNodegroup,
|
||||
deleteKubernetesFargateProfile,
|
||||
deleteKubernetesNodegroup,
|
||||
listKubernetesFargateProfiles,
|
||||
listKubernetesNodegroups,
|
||||
} from '@/api/cloudProxyClient'
|
||||
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||
import {
|
||||
type CreateKubernetesFargateProfileInput,
|
||||
type CreateKubernetesNodegroupInput,
|
||||
type KubernetesFargateProfile,
|
||||
type KubernetesNodegroup,
|
||||
} from '@/types/resource'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
|
||||
interface K8sEngineDetailsProps {
|
||||
cloud: CloudProvider
|
||||
clusterName: string
|
||||
}
|
||||
|
||||
export function K8sEngineDetails({cloud, clusterName}: K8sEngineDetailsProps) {
|
||||
if (cloud !== 'aws') {
|
||||
return (
|
||||
<section className="inspector-section">
|
||||
<p className="metric-label">k8s Engine Capabilities</p>
|
||||
<p className="muted compact-text">Nodegroups and Fargate profiles are currently wired for AWS EKS.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EksNodegroupsSection cloud={cloud} clusterName={clusterName}/>
|
||||
<EksFargateProfilesSection cloud={cloud} clusterName={clusterName}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EksNodegroupsSection({cloud, clusterName}: {cloud: CloudProvider; clusterName: string}) {
|
||||
const queryClient = useQueryClient()
|
||||
const queryKey = ['k8s', cloud, clusterName, 'nodegroups'] as const
|
||||
const nodegroupsQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: ({signal}) => listKubernetesNodegroups(cloud, clusterName, signal),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const createNodegroup = useMutation({
|
||||
mutationFn: (input: CreateKubernetesNodegroupInput) => createKubernetesNodegroup(cloud, clusterName, input),
|
||||
onSuccess: () => invalidateK8sQueries(queryClient, cloud, clusterName),
|
||||
})
|
||||
const deleteNodegroup = useMutation({
|
||||
mutationFn: (nodegroupId: string) => deleteKubernetesNodegroup(cloud, clusterName, nodegroupId),
|
||||
onSuccess: () => invalidateK8sQueries(queryClient, cloud, clusterName),
|
||||
})
|
||||
const [deleteCandidate, setDeleteCandidate] = useState<string | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
nodeRole: '',
|
||||
subnets: '',
|
||||
instanceTypes: 't3.medium',
|
||||
minSize: '1',
|
||||
desiredSize: '1',
|
||||
maxSize: '2',
|
||||
})
|
||||
const canCreate = form.name.trim() && form.nodeRole.trim() && parseCsv(form.subnets).length > 0
|
||||
|
||||
return (
|
||||
<section className="inspector-section">
|
||||
<div className="inspector-section-header">
|
||||
<p className="metric-label">Managed Nodegroups</p>
|
||||
<button className="button compact" type="button" onClick={() => nodegroupsQuery.refetch()}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
className="inspector-action-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!canCreate) return
|
||||
|
||||
createNodegroup.mutate({
|
||||
name: form.name.trim(),
|
||||
nodeRole: form.nodeRole.trim(),
|
||||
subnets: parseCsv(form.subnets),
|
||||
instanceTypes: parseCsv(form.instanceTypes),
|
||||
scalingConfig: {
|
||||
minSize: Number(form.minSize),
|
||||
desiredSize: Number(form.desiredSize),
|
||||
maxSize: Number(form.maxSize),
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
<input className="input" value={form.name} onChange={(event) => setForm({...form, name: event.target.value})} placeholder="Nodegroup name"/>
|
||||
<input className="input" value={form.nodeRole} onChange={(event) => setForm({...form, nodeRole: event.target.value})} placeholder="Node role ARN"/>
|
||||
<input className="input" value={form.subnets} onChange={(event) => setForm({...form, subnets: event.target.value})} placeholder="subnet-a, subnet-b"/>
|
||||
<input className="input" value={form.instanceTypes} onChange={(event) => setForm({...form, instanceTypes: event.target.value})} placeholder="t3.medium"/>
|
||||
<input className="input" type="number" min="0" value={form.minSize} onChange={(event) => setForm({...form, minSize: event.target.value})} placeholder="Min"/>
|
||||
<input className="input" type="number" min="0" value={form.desiredSize} onChange={(event) => setForm({...form, desiredSize: event.target.value})} placeholder="Desired"/>
|
||||
<input className="input" type="number" min="1" value={form.maxSize} onChange={(event) => setForm({...form, maxSize: event.target.value})} placeholder="Max"/>
|
||||
<button className="button compact" type="submit" disabled={!canCreate || createNodegroup.isPending}>
|
||||
<Plus size={13}/>
|
||||
{createNodegroup.isPending ? 'Creating' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
{createNodegroup.isError && <p className="error-text compact-text">{errorMessage(createNodegroup.error)}</p>}
|
||||
{deleteNodegroup.isError && <p className="error-text compact-text">{errorMessage(deleteNodegroup.error)}</p>}
|
||||
<NodegroupsList
|
||||
deleteCandidate={deleteCandidate}
|
||||
deletingName={deleteNodegroup.variables}
|
||||
isError={nodegroupsQuery.isError}
|
||||
isLoading={nodegroupsQuery.isLoading}
|
||||
nodegroups={nodegroupsQuery.data ?? []}
|
||||
onCancelDelete={() => setDeleteCandidate(null)}
|
||||
onConfirmDelete={(nodegroupName) => {
|
||||
deleteNodegroup.mutate(nodegroupName)
|
||||
setDeleteCandidate(null)
|
||||
}}
|
||||
onRequestDelete={setDeleteCandidate}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EksFargateProfilesSection({cloud, clusterName}: {cloud: CloudProvider; clusterName: string}) {
|
||||
const queryClient = useQueryClient()
|
||||
const queryKey = ['k8s', cloud, clusterName, 'fargate-profiles'] as const
|
||||
const profilesQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: ({signal}) => listKubernetesFargateProfiles(cloud, clusterName, signal),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const createProfile = useMutation({
|
||||
mutationFn: (input: CreateKubernetesFargateProfileInput) => createKubernetesFargateProfile(cloud, clusterName, input),
|
||||
onSuccess: () => invalidateK8sQueries(queryClient, cloud, clusterName),
|
||||
})
|
||||
const deleteProfile = useMutation({
|
||||
mutationFn: (profileId: string) => deleteKubernetesFargateProfile(cloud, clusterName, profileId),
|
||||
onSuccess: () => invalidateK8sQueries(queryClient, cloud, clusterName),
|
||||
})
|
||||
const [deleteCandidate, setDeleteCandidate] = useState<string | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
roleArn: '',
|
||||
namespace: 'default',
|
||||
labels: '',
|
||||
subnets: '',
|
||||
})
|
||||
const canCreate = form.name.trim() && form.roleArn.trim() && form.namespace.trim()
|
||||
|
||||
return (
|
||||
<section className="inspector-section">
|
||||
<div className="inspector-section-header">
|
||||
<p className="metric-label">Fargate Profiles</p>
|
||||
<button className="button compact" type="button" onClick={() => profilesQuery.refetch()}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
className="inspector-action-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!canCreate) return
|
||||
|
||||
createProfile.mutate({
|
||||
name: form.name.trim(),
|
||||
podExecutionRoleArn: form.roleArn.trim(),
|
||||
subnets: parseCsv(form.subnets),
|
||||
selectors: [{
|
||||
namespace: form.namespace.trim(),
|
||||
labels: parseKeyValueCsv(form.labels),
|
||||
}],
|
||||
})
|
||||
}}
|
||||
>
|
||||
<input className="input" value={form.name} onChange={(event) => setForm({...form, name: event.target.value})} placeholder="Profile name"/>
|
||||
<input className="input" value={form.roleArn} onChange={(event) => setForm({...form, roleArn: event.target.value})} placeholder="Pod execution role ARN"/>
|
||||
<input className="input" value={form.namespace} onChange={(event) => setForm({...form, namespace: event.target.value})} placeholder="Namespace"/>
|
||||
<input className="input" value={form.labels} onChange={(event) => setForm({...form, labels: event.target.value})} placeholder="app=api, tier=backend"/>
|
||||
<input className="input" value={form.subnets} onChange={(event) => setForm({...form, subnets: event.target.value})} placeholder="Optional subnets"/>
|
||||
<button className="button compact" type="submit" disabled={!canCreate || createProfile.isPending}>
|
||||
<Plus size={13}/>
|
||||
{createProfile.isPending ? 'Creating' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
{createProfile.isError && <p className="error-text compact-text">{errorMessage(createProfile.error)}</p>}
|
||||
{deleteProfile.isError && <p className="error-text compact-text">{errorMessage(deleteProfile.error)}</p>}
|
||||
<FargateProfilesList
|
||||
deleteCandidate={deleteCandidate}
|
||||
deletingName={deleteProfile.variables}
|
||||
isError={profilesQuery.isError}
|
||||
isLoading={profilesQuery.isLoading}
|
||||
profiles={profilesQuery.data ?? []}
|
||||
onCancelDelete={() => setDeleteCandidate(null)}
|
||||
onConfirmDelete={(profileName) => {
|
||||
deleteProfile.mutate(profileName)
|
||||
setDeleteCandidate(null)
|
||||
}}
|
||||
onRequestDelete={setDeleteCandidate}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function NodegroupsList({
|
||||
nodegroups,
|
||||
isLoading,
|
||||
isError,
|
||||
deletingName,
|
||||
deleteCandidate,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
}: {
|
||||
nodegroups: KubernetesNodegroup[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
deletingName?: string
|
||||
deleteCandidate: string | null
|
||||
onRequestDelete: (nodegroupName: string) => void
|
||||
onConfirmDelete: (nodegroupName: string) => void
|
||||
onCancelDelete: () => void
|
||||
}) {
|
||||
if (isLoading) return <p className="muted compact-text">Loading nodegroups.</p>
|
||||
if (isError) return <p className="error-text compact-text">Failed to load nodegroups.</p>
|
||||
if (nodegroups.length === 0) return <p className="muted compact-text">No managed nodegroups returned for this cluster.</p>
|
||||
|
||||
return (
|
||||
<div className="snapshot-list">
|
||||
{nodegroups.map((nodegroup) => (
|
||||
<div className="snapshot-row" key={nodegroup.arn ?? nodegroup.name}>
|
||||
<div>
|
||||
<strong>{nodegroup.name}</strong>
|
||||
<span>{nodegroup.instanceTypes.join(', ') || 'No instance types'} · {formatScaling(nodegroup)}</span>
|
||||
</div>
|
||||
{deleteCandidate === nodegroup.name ? (
|
||||
<span className="inline-actions">
|
||||
<button className="button compact danger" type="button" onClick={() => onConfirmDelete(nodegroup.name)}>Delete</button>
|
||||
<button className="button compact" type="button" onClick={onCancelDelete}>Cancel</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
aria-label={`Delete ${nodegroup.name}`}
|
||||
className="icon-btn danger"
|
||||
type="button"
|
||||
disabled={deletingName === nodegroup.name}
|
||||
onClick={() => onRequestDelete(nodegroup.name)}
|
||||
>
|
||||
<Trash2 size={13}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FargateProfilesList({
|
||||
profiles,
|
||||
isLoading,
|
||||
isError,
|
||||
deletingName,
|
||||
deleteCandidate,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
}: {
|
||||
profiles: KubernetesFargateProfile[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
deletingName?: string
|
||||
deleteCandidate: string | null
|
||||
onRequestDelete: (profileName: string) => void
|
||||
onConfirmDelete: (profileName: string) => void
|
||||
onCancelDelete: () => void
|
||||
}) {
|
||||
if (isLoading) return <p className="muted compact-text">Loading Fargate profiles.</p>
|
||||
if (isError) return <p className="error-text compact-text">Failed to load Fargate profiles.</p>
|
||||
if (profiles.length === 0) return <p className="muted compact-text">No Fargate profiles returned for this cluster.</p>
|
||||
|
||||
return (
|
||||
<div className="snapshot-list">
|
||||
{profiles.map((profile) => (
|
||||
<div className="snapshot-row" key={profile.arn ?? profile.name}>
|
||||
<div>
|
||||
<strong>{profile.name}</strong>
|
||||
<span>{profile.selectors.map((selector) => selector.namespace ?? '*').join(', ') || 'No selectors'}</span>
|
||||
</div>
|
||||
{deleteCandidate === profile.name ? (
|
||||
<span className="inline-actions">
|
||||
<button className="button compact danger" type="button" onClick={() => onConfirmDelete(profile.name)}>Delete</button>
|
||||
<button className="button compact" type="button" onClick={onCancelDelete}>Cancel</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
aria-label={`Delete ${profile.name}`}
|
||||
className="icon-btn danger"
|
||||
type="button"
|
||||
disabled={deletingName === profile.name}
|
||||
onClick={() => onRequestDelete(profile.name)}
|
||||
>
|
||||
<Trash2 size={13}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function parseCsv(value: string) {
|
||||
return value
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function parseKeyValueCsv(value: string) {
|
||||
return Object.fromEntries(
|
||||
parseCsv(value)
|
||||
.map((part) => part.split('='))
|
||||
.filter(([key, val]) => key?.trim() && val?.trim())
|
||||
.map(([key, val]) => [key.trim(), val.trim()]),
|
||||
)
|
||||
}
|
||||
|
||||
function formatScaling(nodegroup: KubernetesNodegroup) {
|
||||
const scaling = nodegroup.scalingConfig
|
||||
if (!scaling) return 'No scaling config'
|
||||
return `${scaling.minSize ?? '?'}/${scaling.desiredSize ?? '?'}/${scaling.maxSize ?? '?'}`
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
return 'The EKS operation failed.'
|
||||
}
|
||||
|
||||
function invalidateK8sQueries(queryClient: ReturnType<typeof useQueryClient>, cloud: CloudProvider, clusterName: string) {
|
||||
void queryClient.invalidateQueries({queryKey: ['k8s', cloud, clusterName]})
|
||||
void queryClient.invalidateQueries({queryKey: ['cloud-resources', cloud, 'k8s']})
|
||||
void queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources(cloud, 'k8s')})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {Activity, Info} from 'lucide-react'
|
||||
import {EmptyState} from '@/components/EmptyState'
|
||||
|
||||
export function PlaceholderPage({title, description}: { title: string; description: string }) {
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>{title}</h1>
|
||||
<span className="info-link">
|
||||
<Info size={11}/>
|
||||
Info
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState icon={Activity} title={`${title} is not available yet`} description={description}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Info,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { EmptyState } from '@/components/EmptyState'
|
||||
import type { SecretSummary } from '@/api/aws/secretsmanager.api'
|
||||
import {
|
||||
secretsManagerQueryKeys,
|
||||
useSecretDetailQuery,
|
||||
useSecretsQuery,
|
||||
useSecretValueQuery,
|
||||
} from '@/api/aws/secretsmanager.queries'
|
||||
import {
|
||||
useCreateSecretMutation,
|
||||
useDeleteSecretMutation,
|
||||
usePutSecretValueMutation,
|
||||
} from '@/api/aws/secretsmanager.mutations'
|
||||
import { timeAgo } from '@/lib/utils'
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
|
||||
type JsonValueKind = 'string' | 'number' | 'boolean' | 'null' | 'json'
|
||||
|
||||
type SecretKeyValueEntry = {
|
||||
id: string
|
||||
key: string
|
||||
value: string
|
||||
kind: JsonValueKind
|
||||
}
|
||||
|
||||
type SerializedKeyValueEntries =
|
||||
| { secretString: string }
|
||||
| { error: string }
|
||||
|
||||
function valueKind(value: JsonValue): JsonValueKind {
|
||||
if (value === null) return 'null'
|
||||
if (Array.isArray(value) || typeof value === 'object') return 'json'
|
||||
if (typeof value === 'number') return 'number'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
function valueText(value: JsonValue): string {
|
||||
return typeof value === 'string' ? value : JSON.stringify(value)
|
||||
}
|
||||
|
||||
function parseKeyValueEntries(secretString: string): SecretKeyValueEntry[] | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(secretString)
|
||||
if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') return null
|
||||
|
||||
return Object.entries(parsed as Record<string, JsonValue>).map(([key, value], index) => ({
|
||||
id: `${index}-${key}`,
|
||||
key,
|
||||
value: valueText(value),
|
||||
kind: valueKind(value),
|
||||
}))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseEntryValue(entry: SecretKeyValueEntry): JsonValue | undefined {
|
||||
if (entry.kind === 'string') return entry.value
|
||||
|
||||
if (entry.kind === 'number') {
|
||||
const value = Number(entry.value)
|
||||
return Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
if (entry.kind === 'boolean') {
|
||||
if (entry.value === 'true') return true
|
||||
if (entry.value === 'false') return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const value: unknown = JSON.parse(entry.value)
|
||||
if (entry.kind === 'null') return value === null ? null : undefined
|
||||
return value as JsonValue
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function serializeKeyValueEntries(entries: SecretKeyValueEntry[]): SerializedKeyValueEntries {
|
||||
const output: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = entry.key
|
||||
if (!key) return { error: 'Every entry needs a key.' }
|
||||
if (Object.prototype.hasOwnProperty.call(output, key)) return { error: `Duplicate key: ${key}` }
|
||||
|
||||
const value = parseEntryValue(entry)
|
||||
if (value === undefined) {
|
||||
return { error: `Invalid ${entry.kind} value for ${key}.` }
|
||||
}
|
||||
output[key] = value
|
||||
}
|
||||
|
||||
return { secretString: JSON.stringify(output, null, 2) }
|
||||
}
|
||||
|
||||
// ─── Create secret form ─────────────────────────────────────────────────────────
|
||||
|
||||
function CreateSecretForm({ onClose }: { onClose: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [secretString, setSecretString] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const createMut = useCreateSecretMutation({
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['resources', 'secretsmanager'] })
|
||||
onClose()
|
||||
},
|
||||
onError: (e) => setErr(e instanceof Error ? e.message : 'Create failed'),
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return setErr('Secret name is required')
|
||||
if (!secretString) return setErr('Secret value is required')
|
||||
setErr('')
|
||||
createMut.mutate({ name: name.trim(), secretString, description: description.trim() || undefined })
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: 14, background: 'var(--raised)', border: '1px solid var(--border)', borderRadius: 8, marginBottom: 12 }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<KeyRound size={14} style={{ color: 'var(--accent)' }} />
|
||||
<strong style={{ fontSize: 13 }}>Create secret</strong>
|
||||
<button type="button" className="icon-btn" style={{ marginLeft: 'auto' }} onClick={onClose} aria-label="Close" title="Close">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Secret name (e.g. prod/db/password)"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setErr('') }}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="json-editor"
|
||||
style={{ minHeight: 100 }}
|
||||
placeholder={'Secret value — plaintext or JSON, e.g.\n{\n "username": "admin",\n "password": "…"\n}'}
|
||||
value={secretString}
|
||||
onChange={(e) => { setSecretString(e.target.value); setErr('') }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{err && <span style={{ fontSize: 12, color: 'var(--status-error)' }}>{err}</span>}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="button" onClick={onClose}>Cancel</button>
|
||||
<button type="submit" className="button primary" disabled={createMut.isPending}>
|
||||
{createMut.isPending ? <Loader2 size={13} className="spin" /> : <Plus size={13} />}
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Secret detail drawer ─────────────────────────────────────────────────────────
|
||||
|
||||
function SecretDrawer({
|
||||
secretId,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: {
|
||||
secretId: string | null
|
||||
onClose: () => void
|
||||
onDeleted: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [tab, setTab] = useState<'details' | 'value'>('details')
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draftValue, setDraftValue] = useState('')
|
||||
const [valueViewMode, setValueViewMode] = useState<'key-value' | 'raw'>('raw')
|
||||
const [editorMode, setEditorMode] = useState<'key-value' | 'raw'>('raw')
|
||||
const [keyValueEntries, setKeyValueEntries] = useState<SecretKeyValueEntry[]>([])
|
||||
const [editorError, setEditorError] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||
const [forceDelete, setForceDelete] = useState(false)
|
||||
|
||||
// Reset transient state when the selected secret changes (via key prop), and
|
||||
// evict any fetched plaintext from the cache so it never outlives this view.
|
||||
useEffect(() => {
|
||||
setTab('details')
|
||||
setRevealed(false)
|
||||
setEditing(false)
|
||||
setDraftValue('')
|
||||
setValueViewMode('raw')
|
||||
setEditorMode('raw')
|
||||
setKeyValueEntries([])
|
||||
setEditorError('')
|
||||
setCopied(false)
|
||||
setDeleteConfirm(false)
|
||||
setForceDelete(false)
|
||||
return () => {
|
||||
qc.removeQueries({ queryKey: secretsManagerQueryKeys.value(secretId) })
|
||||
}
|
||||
}, [secretId, qc])
|
||||
|
||||
// Auto-hide when the user leaves the Value tab. Flipping `revealed` is not
|
||||
// enough — the fetched plaintext stays in the React Query cache (readable via
|
||||
// DevTools/other components) until its entry is evicted, so drop it here and
|
||||
// discard any in-progress edit as well.
|
||||
useEffect(() => {
|
||||
if (tab === 'value') return
|
||||
setRevealed(false)
|
||||
setEditing(false)
|
||||
setDraftValue('')
|
||||
setValueViewMode('raw')
|
||||
setEditorMode('raw')
|
||||
setKeyValueEntries([])
|
||||
setEditorError('')
|
||||
qc.removeQueries({ queryKey: secretsManagerQueryKeys.value(secretId) })
|
||||
}, [tab, secretId, qc])
|
||||
|
||||
const detailQuery = useSecretDetailQuery(secretId)
|
||||
|
||||
// The plaintext value is only fetched once the user explicitly reveals it.
|
||||
const valueQuery = useSecretValueQuery(secretId, revealed && tab === 'value')
|
||||
|
||||
const putMut = usePutSecretValueMutation({
|
||||
onSuccess: () => {
|
||||
setEditing(false)
|
||||
setDraftValue('')
|
||||
},
|
||||
onError: (e) => alert(`Update failed: ${e instanceof Error ? e.message : e}`),
|
||||
})
|
||||
|
||||
const deleteMut = useDeleteSecretMutation({
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['resources', 'secretsmanager'] })
|
||||
onDeleted()
|
||||
},
|
||||
onError: (e) => alert(`Delete failed: ${e instanceof Error ? e.message : e}`),
|
||||
})
|
||||
|
||||
const detail = detailQuery.data
|
||||
const value = valueQuery.data
|
||||
// A binary secret has no SecretString. The API only writes SecretString, so
|
||||
// editing one here would overwrite the binary value with text — block it.
|
||||
const isBinary = Boolean(value && value.secretString === undefined && value.secretBinary !== undefined)
|
||||
const valueEntries = value?.secretString === undefined ? null : parseKeyValueEntries(value.secretString)
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealed || tab !== 'value' || editing) return
|
||||
setValueViewMode(parseKeyValueEntries(value?.secretString ?? '') ? 'key-value' : 'raw')
|
||||
}, [editing, revealed, tab, value?.secretString])
|
||||
|
||||
// Hiding must evict the fetched plaintext from the cache, not merely flip the
|
||||
// reveal flag — otherwise the value lingers in memory (DevTools, extensions,
|
||||
// other components) after the user hides it.
|
||||
function hideValue() {
|
||||
setRevealed(false)
|
||||
qc.removeQueries({ queryKey: secretsManagerQueryKeys.value(secretId) })
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
setEditing(false)
|
||||
setDraftValue('')
|
||||
setEditorMode('raw')
|
||||
setKeyValueEntries([])
|
||||
setEditorError('')
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
if (isBinary) return
|
||||
const secretString = value?.secretString ?? ''
|
||||
const entries = parseKeyValueEntries(secretString)
|
||||
setDraftValue(secretString)
|
||||
setKeyValueEntries(entries ?? [])
|
||||
setEditorMode(valueViewMode === 'key-value' && entries ? 'key-value' : 'raw')
|
||||
setEditorError('')
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
function switchEditorMode(mode: 'key-value' | 'raw') {
|
||||
if (mode === editorMode) return
|
||||
|
||||
if (mode === 'key-value') {
|
||||
const entries = parseKeyValueEntries(draftValue)
|
||||
if (!entries) {
|
||||
setEditorError('Key-value editing is available only for a JSON object.')
|
||||
return
|
||||
}
|
||||
setKeyValueEntries(entries)
|
||||
setEditorError('')
|
||||
setEditorMode('key-value')
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = serializeKeyValueEntries(keyValueEntries)
|
||||
if ('error' in serialized) {
|
||||
setEditorError(serialized.error)
|
||||
return
|
||||
}
|
||||
setDraftValue(serialized.secretString)
|
||||
setEditorError('')
|
||||
setEditorMode('raw')
|
||||
}
|
||||
|
||||
function updateKeyValueEntry(id: string, field: 'key' | 'value', nextValue: string) {
|
||||
setKeyValueEntries((entries) => entries.map((entry) => (
|
||||
entry.id === id ? { ...entry, [field]: nextValue } : entry
|
||||
)))
|
||||
setEditorError('')
|
||||
}
|
||||
|
||||
function addKeyValueEntry() {
|
||||
setKeyValueEntries((entries) => [
|
||||
...entries,
|
||||
{ id: crypto.randomUUID(), key: '', value: '', kind: 'string' },
|
||||
])
|
||||
setEditorError('')
|
||||
}
|
||||
|
||||
function removeKeyValueEntry(id: string) {
|
||||
setKeyValueEntries((entries) => entries.filter((entry) => entry.id !== id))
|
||||
setEditorError('')
|
||||
}
|
||||
|
||||
function saveValue() {
|
||||
let secretString = draftValue
|
||||
|
||||
if (editorMode === 'key-value') {
|
||||
const serialized = serializeKeyValueEntries(keyValueEntries)
|
||||
if ('error' in serialized) {
|
||||
setEditorError(serialized.error)
|
||||
return
|
||||
}
|
||||
secretString = serialized.secretString
|
||||
}
|
||||
|
||||
if (!secretString) {
|
||||
setEditorError('Secret value is required.')
|
||||
return
|
||||
}
|
||||
|
||||
putMut.mutate({ id: secretId!, secretString })
|
||||
}
|
||||
|
||||
async function copyValue() {
|
||||
const text = value?.secretString ?? value?.secretBinary
|
||||
if (text === undefined || text === null) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// Clipboard access can be denied; silently ignore.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`tag-drawer ${secretId ? 'open' : ''}`} style={{ width: 440 }}>
|
||||
<div className="tag-drawer-header">
|
||||
<KeyRound size={14} style={{ color: 'var(--accent)' }} />
|
||||
<h3 title={detail?.name ?? secretId ?? ''}>{detail?.name ?? secretId}</h3>
|
||||
<button className="icon-btn" onClick={onClose} aria-label="Close" title="Close"><X size={14} aria-hidden="true" /></button>
|
||||
</div>
|
||||
|
||||
<div className="drawer-tabs">
|
||||
<button className={`drawer-tab ${tab === 'details' ? 'active' : ''}`} onClick={() => setTab('details')}>
|
||||
Details
|
||||
</button>
|
||||
<button className={`drawer-tab ${tab === 'value' ? 'active' : ''}`} onClick={() => setTab('value')}>
|
||||
Value
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tag-drawer-body">
|
||||
{/* ── Details tab ── */}
|
||||
{tab === 'details' && (
|
||||
detailQuery.isLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-3)', fontSize: 13 }}>
|
||||
<Loader2 size={14} className="spin" /> Loading details…
|
||||
</div>
|
||||
) : detailQuery.isError ? (
|
||||
<p style={{ color: 'var(--status-error)', fontSize: 13 }}>Failed to load secret details.</p>
|
||||
) : detail ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className="badge" style={{ background: 'rgba(34,197,94,0.14)', color: 'var(--status-ok)' }}>
|
||||
{detail.rotationEnabled ? 'Rotation enabled' : 'Active'}
|
||||
</span>
|
||||
{detail.versionIds.length > 0 && (
|
||||
<span className="badge" style={{ background: 'rgba(107,114,128,0.14)', color: 'var(--text-3)' }}>
|
||||
{detail.versionIds.length} version{detail.versionIds.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="meta-grid">
|
||||
{detail.arn && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">ARN</span>
|
||||
<span className="meta-value" style={{ fontSize: 11 }}>{detail.arn}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.description && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Description</span>
|
||||
<span className="meta-value" style={{ fontFamily: 'inherit', color: 'var(--text-3)' }}>{detail.description}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.kmsKeyId && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">KMS key</span>
|
||||
<span className="meta-value" style={{ fontSize: 11 }}>{detail.kmsKeyId}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
{detail.createdDate && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Created</span>
|
||||
<span className="meta-value" style={{ color: 'var(--text-3)' }}>{timeAgo(detail.createdDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.lastChangedDate && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Last changed</span>
|
||||
<span className="meta-value" style={{ color: 'var(--text-3)' }}>{timeAgo(detail.lastChangedDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.lastAccessedDate && (
|
||||
<div className="meta-row">
|
||||
<span className="meta-label">Last accessed</span>
|
||||
<span className="meta-value" style={{ color: 'var(--text-3)' }}>{timeAgo(detail.lastAccessedDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{detail.tags.length > 0 && (
|
||||
<div>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 6px' }}>
|
||||
Tags ({detail.tags.length})
|
||||
</p>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
{detail.tags.map((tag, i) => (
|
||||
<div
|
||||
key={tag.key}
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', borderBottom: i < detail.tags.length - 1 ? '1px solid var(--border)' : undefined }}
|
||||
>
|
||||
<div style={{ padding: '6px 8px', borderRight: '1px solid var(--border)', fontSize: 12, fontFamily: 'monospace', color: 'var(--status-warn)' }}>{tag.key}</div>
|
||||
<div style={{ padding: '6px 8px', fontSize: 12, fontFamily: 'monospace', color: 'var(--text-2)' }} title={tag.value}>{tag.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
|
||||
{/* ── Value tab ── */}
|
||||
{tab === 'value' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{!revealed && !editing ? (
|
||||
<button className="button" onClick={() => setRevealed(true)} style={{ alignSelf: 'flex-start' }}>
|
||||
<Eye size={13} />
|
||||
Reveal secret value
|
||||
</button>
|
||||
) : editing ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', margin: 0 }}>
|
||||
New secret value
|
||||
</p>
|
||||
<div className="drawer-tabs" style={{ marginLeft: 'auto' }}>
|
||||
<button
|
||||
className={`drawer-tab ${editorMode === 'key-value' ? 'active' : ''}`}
|
||||
onClick={() => switchEditorMode('key-value')}
|
||||
>
|
||||
Key-value
|
||||
</button>
|
||||
<button
|
||||
className={`drawer-tab ${editorMode === 'raw' ? 'active' : ''}`}
|
||||
onClick={() => switchEditorMode('raw')}
|
||||
>
|
||||
Plain text
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{editorMode === 'key-value' ? (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 32px', gap: 6, padding: '7px 8px', borderBottom: '1px solid var(--border)', color: 'var(--text-3)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<span>Key</span>
|
||||
<span>Value</span>
|
||||
<span />
|
||||
</div>
|
||||
{keyValueEntries.map((entry) => (
|
||||
<div key={entry.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 32px', gap: 6, padding: 8, borderBottom: '1px solid var(--border)' }}>
|
||||
<input
|
||||
className="input"
|
||||
aria-label="Secret key"
|
||||
value={entry.key}
|
||||
onChange={(e) => updateKeyValueEntry(entry.id, 'key', e.target.value)}
|
||||
placeholder="Key"
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
aria-label={`Value for ${entry.key || 'new key'}`}
|
||||
value={entry.value}
|
||||
onChange={(e) => updateKeyValueEntry(entry.id, 'value', e.target.value)}
|
||||
placeholder="Value"
|
||||
/>
|
||||
<button className="icon-btn" title="Delete entry" aria-label={`Delete ${entry.key || 'entry'}`} onClick={() => removeKeyValueEntry(entry.id)}>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ padding: 8 }}>
|
||||
<button className="button" onClick={addKeyValueEntry}>
|
||||
<Plus size={13} />
|
||||
Add key-value pair
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
className="json-editor"
|
||||
style={{ minHeight: 180 }}
|
||||
value={draftValue}
|
||||
onChange={(e) => { setDraftValue(e.target.value); setEditorError('') }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
{editorError && <span style={{ fontSize: 12, color: 'var(--status-error)' }}>{editorError}</span>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="button primary" disabled={putMut.isPending} onClick={saveValue}>
|
||||
{putMut.isPending ? <Loader2 size={13} className="spin" /> : <Save size={13} />}
|
||||
Save new version
|
||||
</button>
|
||||
<button className="button" onClick={cancelEditing}>Cancel</button>
|
||||
</div>
|
||||
</>
|
||||
) : valueQuery.isLoading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-3)', fontSize: 13 }}>
|
||||
<Loader2 size={14} className="spin" /> Loading value…
|
||||
</div>
|
||||
) : valueQuery.isError ? (
|
||||
<p style={{ color: 'var(--status-error)', fontSize: 13 }}>Failed to load secret value.</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
{value?.secretBinary ? 'Binary value (base64)' : 'Secret value'}
|
||||
</span>
|
||||
<button className="icon-btn" style={{ marginLeft: 'auto' }} title="Hide" onClick={hideValue}>
|
||||
<EyeOff size={13} />
|
||||
</button>
|
||||
<button className="icon-btn" title="Copy" onClick={copyValue}>
|
||||
{copied ? <Check size={13} color="var(--status-ok)" /> : <Copy size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
{!isBinary && valueEntries && (
|
||||
<div className="drawer-tabs" style={{ alignSelf: 'flex-start' }}>
|
||||
<button className={`drawer-tab ${valueViewMode === 'key-value' ? 'active' : ''}`} onClick={() => setValueViewMode('key-value')}>
|
||||
Key-value
|
||||
</button>
|
||||
<button className={`drawer-tab ${valueViewMode === 'raw' ? 'active' : ''}`} onClick={() => setValueViewMode('raw')}>
|
||||
Plain text
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{valueViewMode === 'key-value' && valueEntries ? (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, padding: '7px 8px', borderBottom: '1px solid var(--border)', color: 'var(--text-3)', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
<span>Key</span>
|
||||
<span>Value</span>
|
||||
</div>
|
||||
{valueEntries.length === 0 ? (
|
||||
<p style={{ margin: 0, padding: 10, color: 'var(--text-3)', fontSize: 12 }}>Empty JSON object</p>
|
||||
) : valueEntries.map((entry, index) => (
|
||||
<div key={entry.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, padding: 8, borderBottom: index < valueEntries.length - 1 ? '1px solid var(--border)' : undefined }}>
|
||||
<span className="mono" style={{ color: 'var(--status-warn)', fontSize: 12, overflowWrap: 'anywhere' }}>{entry.key}</span>
|
||||
<span className="mono" style={{ color: 'var(--text-2)', fontSize: 12, overflowWrap: 'anywhere' }}>{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="json-editor" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', margin: 0 }}>
|
||||
{value?.secretString ?? value?.secretBinary ?? '(empty)'}
|
||||
</pre>
|
||||
)}
|
||||
{value?.versionId && (
|
||||
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>Version: {value.versionId}</span>
|
||||
)}
|
||||
{isBinary ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||||
Binary secrets are read-only here; editing would overwrite the binary value with text.
|
||||
</span>
|
||||
) : (
|
||||
<button className="button" onClick={startEditing} style={{ alignSelf: 'flex-start' }}>
|
||||
<Save size={13} />
|
||||
Edit value
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="tag-drawer-footer">
|
||||
{deleteConfirm ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', padding: '8px', background: 'rgba(239,68,68,0.08)', borderRadius: 4, border: '1px solid rgba(239,68,68,0.3)' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--status-error)' }}>Delete this secret?</span>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--text-2)' }}>
|
||||
<input type="checkbox" checked={forceDelete} onChange={(e) => setForceDelete(e.target.checked)} />
|
||||
Force delete (no 7-day recovery window)
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="button danger" onClick={() => deleteMut.mutate({ id: secretId!, force: forceDelete })} disabled={deleteMut.isPending}>
|
||||
{deleteMut.isPending ? <Loader2 size={12} className="spin" /> : 'Yes, delete'}
|
||||
</button>
|
||||
<button className="button" onClick={() => setDeleteConfirm(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="button danger" style={{ marginLeft: 'auto' }} onClick={() => setDeleteConfirm(true)}>
|
||||
<Trash2 size={13} />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SecretsManagerPage() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const query = useSecretsQuery()
|
||||
|
||||
const secrets = useMemo(() => {
|
||||
const all = query.data ?? []
|
||||
if (!search) return all
|
||||
const q = search.toLowerCase()
|
||||
return all.filter((s) => s.name.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q))
|
||||
}, [query.data, search])
|
||||
|
||||
function selectionId(secret: SecretSummary): string {
|
||||
return secret.arn ?? secret.name
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecretDrawer
|
||||
key={selected}
|
||||
secretId={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onDeleted={() => setSelected(null)}
|
||||
/>
|
||||
|
||||
<div className="page-header">
|
||||
<div className="page-title">
|
||||
<h1>Secrets Manager</h1>
|
||||
<span className="info-link">
|
||||
<Info size={11} />
|
||||
{query.data ? `${query.data.length} secrets` : 'Encrypted secrets'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="button" onClick={() => void query.refetch()}>
|
||||
<RefreshCw size={13} />
|
||||
Refresh
|
||||
</button>
|
||||
<button className="button primary" onClick={() => setCreating((v) => !v)}>
|
||||
<Plus size={13} />
|
||||
Create secret
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-row">
|
||||
<Search size={14} color="var(--text-3)" />
|
||||
<input
|
||||
className="input"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name or description…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
{creating && <CreateSecretForm onClose={() => setCreating(false)} />}
|
||||
|
||||
<div className="table-panel">
|
||||
<div className="widget-header">
|
||||
<h3>Secrets</h3>
|
||||
</div>
|
||||
{query.isError ? (
|
||||
<EmptyState icon={KeyRound} title="Cannot load secrets" description="Secrets Manager did not respond from the Floci endpoint." />
|
||||
) : query.isLoading ? (
|
||||
<div className="empty"><p>Loading secrets…</p></div>
|
||||
) : secrets.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={KeyRound}
|
||||
title={search ? 'No secrets match your search' : 'No secrets'}
|
||||
description={search ? 'Try a different name or description filter.' : 'Create a secret to get started.'}
|
||||
/>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Last changed</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{secrets.map((secret) => {
|
||||
const id = selectionId(secret)
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
style={{ cursor: 'pointer', background: selected === id ? 'var(--raised)' : undefined }}
|
||||
onClick={() => setSelected(selected === id ? null : id)}
|
||||
>
|
||||
<td className="mono" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<KeyRound size={13} style={{ color: 'var(--accent)', flexShrink: 0 }} />
|
||||
{secret.name}
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-3)' }}>{secret.description ?? '—'}</td>
|
||||
<td style={{ color: 'var(--text-3)' }}>{secret.lastChangedDate ? timeAgo(secret.lastChangedDate) : '—'}</td>
|
||||
<td>{secret.tags.length || '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
import {useSyncExternalStore} from 'react'
|
||||
|
||||
// Floci isolates resources per AWS account, and selects the account from the
|
||||
// 12-digit access key it is given (see the API's aws.ts). The console mirrors
|
||||
// that: a single active account at a time, sent on every request via the
|
||||
// `x-floci-account-id` header. This is a context switch, not a filter — accounts
|
||||
// do not overlap in Floci.
|
||||
|
||||
export const DEFAULT_ACCOUNT_ID = '000000000000'
|
||||
/** Request header the API reads to scope a request to an account. */
|
||||
export const ACCOUNT_HEADER = 'x-floci-account-id'
|
||||
const ACCOUNT_KEY = 'floci.accountId'
|
||||
const RECENTS_KEY = 'floci.accountId.recents'
|
||||
const MAX_RECENTS = 8
|
||||
|
||||
export function isAccountId(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && /^\d{12}$/.test(value)
|
||||
}
|
||||
|
||||
function readAccount(): string {
|
||||
try {
|
||||
const stored = localStorage.getItem(ACCOUNT_KEY)
|
||||
if (isAccountId(stored)) return stored
|
||||
} catch {
|
||||
// localStorage unavailable (private mode / SSR) — fall back to default.
|
||||
}
|
||||
return DEFAULT_ACCOUNT_ID
|
||||
}
|
||||
|
||||
function readRecents(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENTS_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) return parsed.filter(isAccountId).slice(0, MAX_RECENTS)
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed storage
|
||||
}
|
||||
return [DEFAULT_ACCOUNT_ID]
|
||||
}
|
||||
|
||||
let currentAccount = readAccount()
|
||||
let recents = readRecents()
|
||||
if (!recents.includes(currentAccount)) recents = [currentAccount, ...recents].slice(0, MAX_RECENTS)
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function persist(): void {
|
||||
try {
|
||||
localStorage.setItem(ACCOUNT_KEY, currentAccount)
|
||||
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents))
|
||||
} catch {
|
||||
// ignore persistence failures
|
||||
}
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
/** Current account id — read synchronously by the API request interceptor. */
|
||||
export function getAccountId(): string {
|
||||
return currentAccount
|
||||
}
|
||||
|
||||
export function getRecents(): string[] {
|
||||
return recents
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active account. Returns false for invalid ids (not 12 digits).
|
||||
* The reference of getRecents()/getAccountId() only changes when something
|
||||
* actually changed, keeping useSyncExternalStore snapshots stable.
|
||||
*/
|
||||
export function setAccountId(id: string): boolean {
|
||||
if (!isAccountId(id)) return false
|
||||
const sameAccount = id === currentAccount
|
||||
const alreadyTop = recents[0] === id
|
||||
if (sameAccount && alreadyTop) return true
|
||||
|
||||
currentAccount = id
|
||||
recents = [id, ...recents.filter((r) => r !== id)].slice(0, MAX_RECENTS)
|
||||
persist()
|
||||
emit()
|
||||
return true
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function useAccountId(): string {
|
||||
return useSyncExternalStore(subscribe, getAccountId, getAccountId)
|
||||
}
|
||||
|
||||
export function useAccountRecents(): string[] {
|
||||
return useSyncExternalStore(subscribe, getRecents, getRecents)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function assert(
|
||||
condition: unknown,
|
||||
message = "Assertion failed",
|
||||
): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertDefined<T>(
|
||||
value: T,
|
||||
message = "Expected value to be defined",
|
||||
): asserts value is NonNullable<T> {
|
||||
if (value == null) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertNever(value: never, message?: string): never {
|
||||
throw new Error(message ?? `Unexpected value: ${String(value)}`);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type {CapabilitySchema, CapabilityStatus, DatabaseActionName, KubernetesActionName, ObjectActionName, ResourceActionName} from '@/types/schema'
|
||||
import type {CloudAvailability} from '@/types/cloud'
|
||||
|
||||
export type CapabilityActionName = ResourceActionName | ObjectActionName | KubernetesActionName | DatabaseActionName
|
||||
export type AnyCapability = CapabilitySchema<CapabilityActionName>
|
||||
export type CapabilityInput<TAction extends CapabilityActionName> = CapabilitySchema<TAction> | TAction
|
||||
|
||||
const actionLabels: Record<CapabilityActionName, string> = {
|
||||
list: 'List',
|
||||
create: 'Create',
|
||||
update: 'Update',
|
||||
delete: 'Delete',
|
||||
inspect: 'Inspect',
|
||||
encrypt: 'Encrypt',
|
||||
decrypt: 'Decrypt',
|
||||
upload: 'Upload',
|
||||
download: 'Download',
|
||||
createFolder: 'Create folder',
|
||||
copy: 'Copy object',
|
||||
invoke: 'Invoke',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
reboot: 'Reboot',
|
||||
updateTags: 'Edit tags',
|
||||
listSnapshots: 'List DB snapshots',
|
||||
createSnapshot: 'Create DB snapshot',
|
||||
listNodegroups: 'List nodegroups',
|
||||
createNodegroup: 'Create nodegroup',
|
||||
deleteNodegroup: 'Delete nodegroup',
|
||||
listFargateProfiles: 'List Fargate profiles',
|
||||
createFargateProfile: 'Create Fargate profile',
|
||||
deleteFargateProfile: 'Delete Fargate profile',
|
||||
}
|
||||
|
||||
export function normalizeCapabilities<TAction extends CapabilityActionName>(capabilities: Array<CapabilityInput<TAction>> = []): Array<CapabilitySchema<TAction>> {
|
||||
return capabilities.map((capability) => {
|
||||
if (typeof capability !== 'string') return capability
|
||||
return {
|
||||
name: capability,
|
||||
label: actionLabels[capability],
|
||||
enabled: true,
|
||||
status: 'available' as CapabilityStatus,
|
||||
runtimeRequired: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function capabilityFor<TAction extends CapabilityActionName>(capabilities: Array<CapabilitySchema<TAction>>, name: TAction): CapabilitySchema<TAction> | undefined {
|
||||
return capabilities.find((capability) => capability.name === name)
|
||||
}
|
||||
|
||||
export function capabilityEnabled(capability: CapabilitySchema<CapabilityActionName> | undefined): boolean {
|
||||
return Boolean(capability?.enabled && capability.status !== 'blocked' && capability.status !== 'coming_soon')
|
||||
}
|
||||
|
||||
export function withRuntimeState<TAction extends CapabilityActionName>(
|
||||
capabilities: Array<CapabilitySchema<TAction>>,
|
||||
runtimeReachable: boolean,
|
||||
): Array<CapabilitySchema<TAction>> {
|
||||
return capabilities.map((capability) => {
|
||||
if (runtimeReachable || !capability.runtimeRequired) return capability
|
||||
return {
|
||||
...capability,
|
||||
enabled: false,
|
||||
status: 'blocked',
|
||||
reason: 'Start the selected runtime before using this action.',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function withServiceAvailability<TAction extends CapabilityActionName>(
|
||||
capabilities: Array<CapabilitySchema<TAction>>,
|
||||
serviceAvailability: CloudAvailability,
|
||||
): Array<CapabilitySchema<TAction>> {
|
||||
if (serviceAvailability === 'available') return capabilities
|
||||
return capabilities.map((capability) => ({
|
||||
...capability,
|
||||
enabled: false,
|
||||
status: 'coming_soon' as CapabilityStatus,
|
||||
reason: 'This provider service schema is available, but no runtime adapter is registered yet.',
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {ReactNode} from 'react'
|
||||
import type {TableColumnSchema} from '@/types/schema'
|
||||
import {formatBytes, formatDateTime, formatRelativeTime, slugify} from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Render one table cell from a schema-declared column.
|
||||
*
|
||||
* Formats are server-declared, so an unknown one degrades to plain text rather
|
||||
* than breaking the table.
|
||||
*/
|
||||
export function renderColumnValue(value: unknown, column: TableColumnSchema): ReactNode {
|
||||
const empty = column.emptyText ?? '-'
|
||||
if (value === null || value === undefined || value === '') return empty
|
||||
|
||||
switch (column.format) {
|
||||
case 'datetime':
|
||||
return formatDateTime(value) ?? empty
|
||||
case 'relative':
|
||||
return formatRelativeTime(value) ?? empty
|
||||
case 'bytes':
|
||||
return typeof value === 'number' ? formatBytes(value) : empty
|
||||
case 'boolean':
|
||||
return value ? 'Yes' : 'No'
|
||||
case 'badge':
|
||||
return <span className={`badge ${slugify(String(value))}`}>{String(value)}</span>
|
||||
case 'code':
|
||||
return <code>{String(value)}</code>
|
||||
case 'list':
|
||||
return Array.isArray(value) ? value.join(', ') || empty : String(value)
|
||||
case 'text':
|
||||
default:
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
export function dataExplorerKind(resource: CloudResource): 'dynamodb' | 'cosmos' | 'sql' | undefined {
|
||||
if (resource.cloud === 'aws' && resource.service === 'nosql' && resource.type === 'dynamodb-table') return 'dynamodb'
|
||||
if (resource.cloud === 'azure' && resource.service === 'nosql' && resource.type === 'cosmos-database') return 'cosmos'
|
||||
if (resource.cloud === 'azure' && resource.service === 'database' &&
|
||||
(resource.type === 'sql-server' || resource.type === 'postgres-flexible-server')) return 'sql'
|
||||
}
|
||||
|
||||
export function dataExplorerPath(resource: CloudResource): string | undefined {
|
||||
if (!dataExplorerKind(resource)) return undefined
|
||||
return `/cloud-explorer/${resource.cloud}/${resource.service}/${encodeURIComponent(resource.id)}/data`
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/** Shared value formatters for tables, inspectors and the object browser. */
|
||||
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '-'
|
||||
if (bytes === 0) return '0 B'
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1)
|
||||
return `${(bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1)} ${BYTE_UNITS[index]}`
|
||||
}
|
||||
|
||||
/** Locale date-time, or null when the value is not a usable timestamp. */
|
||||
export function formatDateTime(value: unknown): string | null {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return null
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date.toLocaleString()
|
||||
}
|
||||
|
||||
const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||
['year', 31_536_000_000],
|
||||
['month', 2_592_000_000],
|
||||
['day', 86_400_000],
|
||||
['hour', 3_600_000],
|
||||
['minute', 60_000],
|
||||
['second', 1_000],
|
||||
]
|
||||
|
||||
/** "3 minutes ago", or null when the value is not a usable timestamp. */
|
||||
export function formatRelativeTime(value: unknown, now: number = Date.now()): string | null {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return null
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
const deltaMs = date.getTime() - now
|
||||
const formatter = new Intl.RelativeTimeFormat(undefined, {numeric: 'auto'})
|
||||
for (const [unit, unitMs] of RELATIVE_UNITS) {
|
||||
if (Math.abs(deltaMs) >= unitMs) {
|
||||
return formatter.format(Math.round(deltaMs / unitMs), unit)
|
||||
}
|
||||
}
|
||||
return formatter.format(0, 'second')
|
||||
}
|
||||
|
||||
/** Kinds that render inside the preview modal. */
|
||||
export type ObjectPreviewKind = 'image' | 'video' | 'audio' | 'text'
|
||||
|
||||
/**
|
||||
* Kinds recognized for a distinct row icon. A superset of ObjectPreviewKind —
|
||||
* these get an icon but no in-browser preview:
|
||||
* - 'pdf': native PDF-in-iframe support is unreliable across browsers (some
|
||||
* ship without a built-in PDF viewer, and browsers/ad-blockers can flag an
|
||||
* iframe navigation that falls back to a download as an automatic-download
|
||||
* attack pattern and block it outright). Download still works normally.
|
||||
* - 'document' (Word) / 'spreadsheet' (Excel): rendering needs a client-side
|
||||
* parser (mammoth, xlsx) that this project doesn't depend on yet.
|
||||
*/
|
||||
export type ObjectIconKind = ObjectPreviewKind | 'pdf' | 'document' | 'spreadsheet'
|
||||
|
||||
const PREVIEW_EXTENSIONS: Record<ObjectPreviewKind, Set<string>> = {
|
||||
image: new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif']),
|
||||
video: new Set(['mp4', 'webm', 'ogv', 'mov']),
|
||||
audio: new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'flac']),
|
||||
text: new Set(['txt', 'json', 'log', 'md', 'yaml', 'yml', 'csv', 'xml', 'html', 'css', 'js', 'ts']),
|
||||
}
|
||||
|
||||
const ICON_ONLY_EXTENSIONS: Record<Exclude<ObjectIconKind, ObjectPreviewKind>, Set<string>> = {
|
||||
pdf: new Set(['pdf']),
|
||||
document: new Set(['doc', 'docx', 'rtf', 'odt']),
|
||||
spreadsheet: new Set(['xls', 'xlsx', 'xlsm', 'ods']),
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort preview/icon-kind detection from an object key/name. Storage
|
||||
* list APIs (S3 ListObjectsV2, Azure list blobs) do not return content-type
|
||||
* per item — only a HEAD/GET on the object would — so this is an extension
|
||||
* heuristic. A wrong guess just fails to render the thumbnail/preview, it
|
||||
* never blocks any other action.
|
||||
*/
|
||||
export function objectIconKind(name: string): ObjectIconKind | null {
|
||||
const ext = name.split('.').pop()?.toLowerCase()
|
||||
if (!ext) return null
|
||||
for (const kind of Object.keys(PREVIEW_EXTENSIONS) as ObjectPreviewKind[]) {
|
||||
if (PREVIEW_EXTENSIONS[kind].has(ext)) return kind
|
||||
}
|
||||
for (const kind of Object.keys(ICON_ONLY_EXTENSIONS) as Array<'pdf' | 'document' | 'spreadsheet'>) {
|
||||
if (ICON_ONLY_EXTENSIONS[kind].has(ext)) return kind
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const ICON_ONLY_KINDS = new Set(Object.keys(ICON_ONLY_EXTENSIONS))
|
||||
|
||||
/** Narrower than {@link objectIconKind}: only kinds the preview modal can render. */
|
||||
export function objectPreviewKind(name: string): ObjectPreviewKind | null {
|
||||
const kind = objectIconKind(name)
|
||||
return kind && !ICON_ONLY_KINDS.has(kind) ? kind as ObjectPreviewKind : null
|
||||
}
|
||||
|
||||
/** Stable class-name fragment for a status value. */
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Cross-cloud network validation utilities.
|
||||
* CIDR and port logic applies equally to AWS VPCs, Azure VNets, GCP VPCs, OCI VCNs, etc.
|
||||
*/
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const CIDR_RE = /^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/
|
||||
|
||||
function parseIpAndPrefix(cidr: string): [number[], number] | null {
|
||||
const m = cidr.trim().match(CIDR_RE)
|
||||
if (!m) return null
|
||||
const octs = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]
|
||||
const prefix = Number(m[5])
|
||||
if (octs.some((o) => o > 255) || prefix > 32) return null
|
||||
return [octs, prefix]
|
||||
}
|
||||
|
||||
/** Zero out host bits of an IP array up to the given prefix length. */
|
||||
function maskIp(octs: number[], prefix: number): number[] {
|
||||
const result = [...octs]
|
||||
let bits = prefix
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (bits >= 8) {
|
||||
bits -= 8
|
||||
} else if (bits > 0) {
|
||||
result[i] = result[i] & ((0xff << (8 - bits)) & 0xff)
|
||||
bits = 0
|
||||
} else {
|
||||
result[i] = 0
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** True when `cidr` is a syntactically valid IPv4 CIDR (e.g. 10.0.0.0/16). */
|
||||
export function isValidCidr(cidr: string): boolean {
|
||||
return parseIpAndPrefix(cidr) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `innerCidr` fits entirely inside `outerCidr`.
|
||||
*
|
||||
* Conditions:
|
||||
* 1. Both must be valid CIDRs.
|
||||
* 2. inner prefix >= outer prefix (inner block cannot be larger than outer).
|
||||
* 3. inner's network address, masked to the outer prefix, equals outer's
|
||||
* network address — confirming inner is actually inside the outer range.
|
||||
*
|
||||
* Example: 10.0.1.0/24 is within 10.0.0.0/16. 10.1.0.0/24 is not.
|
||||
*/
|
||||
export function isCidrWithinBlock(innerCidr: string, outerCidr: string): boolean {
|
||||
const inner = parseIpAndPrefix(innerCidr)
|
||||
const outer = parseIpAndPrefix(outerCidr)
|
||||
if (!inner || !outer) return false
|
||||
|
||||
const [innerOcts, innerPrefix] = inner
|
||||
const [outerOcts, outerPrefix] = outer
|
||||
|
||||
if (innerPrefix < outerPrefix) return false
|
||||
|
||||
const innerMasked = maskIp(innerOcts, outerPrefix)
|
||||
const outerMasked = maskIp(outerOcts, outerPrefix)
|
||||
|
||||
return innerMasked.every((b, i) => b === outerMasked[i])
|
||||
}
|
||||
|
||||
/** True when `port` is an integer in [0, 65535]. */
|
||||
export function isValidPort(port: string): boolean {
|
||||
const n = Number(port)
|
||||
return Number.isInteger(n) && n >= 0 && n <= 65535
|
||||
}
|
||||
|
||||
/**
|
||||
* True when fromPort <= toPort and both are valid.
|
||||
* Always true for protocols that don't use ports ('-1', 'icmp').
|
||||
*/
|
||||
export function isValidPortRange(protocol: string, from: string, to: string): boolean {
|
||||
if (protocol === '-1' || protocol === 'icmp') return true
|
||||
if (!isValidPort(from) || !isValidPort(to)) return false
|
||||
return Number(from) <= Number(to)
|
||||
}
|
||||
|
||||
// ─── Subnet group CIDR calculation ───────────────────────────────────────────
|
||||
|
||||
export type SubnetGroupDef = {
|
||||
name: string
|
||||
count: number
|
||||
isPublic: boolean
|
||||
/** Subnet prefix length override, e.g. 24 for /24. Defaults to min(vpcPrefix+4, 28). */
|
||||
prefix?: number
|
||||
}
|
||||
|
||||
export type SubnetGroupPreview = {
|
||||
name: string
|
||||
isPublic: boolean
|
||||
cidrs: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocates non-overlapping subnet CIDRs for each named group sequentially.
|
||||
* subnetPrefix = min(vpcPrefix + 4, 28). Groups are laid out in declaration
|
||||
* order without gaps — suitable for both the wizard preview and the backend.
|
||||
*
|
||||
* Example: vpcCidr=10.0.0.0/16, groups=[{public,2},{private-app,2},{private-db,1}]
|
||||
* public-1 = 10.0.0.0/20
|
||||
* public-2 = 10.0.16.0/20
|
||||
* private-app-1 = 10.0.32.0/20
|
||||
* private-app-2 = 10.0.48.0/20
|
||||
* private-db-1 = 10.0.64.0/20
|
||||
*/
|
||||
export function calculateSubnetCidrs(
|
||||
vpcCidr: string,
|
||||
groups: SubnetGroupDef[],
|
||||
): SubnetGroupPreview[] {
|
||||
const parsed = parseIpAndPrefix(vpcCidr)
|
||||
if (!parsed) return groups.map((g) => ({ name: g.name, isPublic: g.isPublic, cidrs: [] }))
|
||||
|
||||
const [octs, vpcPrefix] = parsed
|
||||
const vpcBase = octs[0] * 16777216 + octs[1] * 65536 + octs[2] * 256 + octs[3]
|
||||
const defaultPrefix = Math.min(vpcPrefix + 4, 28)
|
||||
|
||||
function baseToCidr(base: number, prefix: number): string {
|
||||
return (
|
||||
`${Math.floor(base / 16777216)}.` +
|
||||
`${Math.floor((base % 16777216) / 65536)}.` +
|
||||
`${Math.floor((base % 65536) / 256)}.` +
|
||||
`${base % 256}/${prefix}`
|
||||
)
|
||||
}
|
||||
|
||||
let currentBase = vpcBase
|
||||
return groups.map((g) => {
|
||||
const prefix = g.prefix ?? defaultPrefix
|
||||
const size = Math.pow(2, 32 - prefix)
|
||||
const cidrs = Array.from({ length: g.count }, (_, i) => baseToCidr(currentBase + i * size, prefix))
|
||||
currentBase += g.count * size
|
||||
return { name: g.name, isPublic: g.isPublic, cidrs }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Read a dotted path out of a resource.
|
||||
*
|
||||
* Table columns default to a top-level lookup, but most provider detail lives
|
||||
* under `metadata`, so a column like `metadata.runtime` needs to walk. Returns
|
||||
* undefined on any non-object hop rather than throwing, because schemas are
|
||||
* server-supplied and may name a field a given runtime does not populate.
|
||||
*/
|
||||
export function getPath(source: unknown, path: string): unknown {
|
||||
if (!path) return undefined
|
||||
|
||||
let current = source
|
||||
for (const segment of path.split('.')) {
|
||||
if (current === null || current === undefined) return undefined
|
||||
if (typeof current !== 'object') return undefined
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number(segment)
|
||||
if (!Number.isInteger(index)) return undefined
|
||||
current = current[index]
|
||||
continue
|
||||
}
|
||||
|
||||
current = (current as Record<string, unknown>)[segment]
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
|
||||
type SidebarPref = 'expanded' | 'collapsed'
|
||||
|
||||
const STORAGE_KEY = 'floci-sidebar'
|
||||
/** Same breakpoint the rest of the console reflows at. */
|
||||
const NARROW_QUERY = '(max-width: 980px)'
|
||||
|
||||
function getStored(): SidebarPref | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return stored === 'collapsed' || stored === 'expanded' ? stored : null
|
||||
} catch {
|
||||
// localStorage unavailable (private mode) — let the viewport decide.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function matchesNarrow(): boolean {
|
||||
return window.matchMedia(NARROW_QUERY).matches
|
||||
}
|
||||
|
||||
/**
|
||||
* True when focus sits inside the sidebar nav — the state that holds the rail
|
||||
* open via the `.sidebar:has(.nav:focus-within)` rule in index.css.
|
||||
*/
|
||||
function navHasFocus(): boolean {
|
||||
const active = document.activeElement
|
||||
return active instanceof HTMLElement && active.closest('.sidebar .nav') !== null
|
||||
}
|
||||
|
||||
/** The shortcut must not fire while the user is typing in the topbar search. */
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT'
|
||||
}
|
||||
|
||||
/**
|
||||
* Sidebar collapse state.
|
||||
*
|
||||
* The preference stays null until the user makes an explicit choice, so the
|
||||
* viewport decides on a first visit. Once they toggle, that choice persists and
|
||||
* the breakpoint stops applying — resizing never undoes a deliberate decision.
|
||||
*/
|
||||
export function useSidebar() {
|
||||
const [pref, setPref] = useState<SidebarPref | null>(getStored)
|
||||
const [narrow, setNarrow] = useState(matchesNarrow)
|
||||
/** Attach to the footer toggle so the shortcut can park focus outside .nav. */
|
||||
const toggleRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const collapsed = pref !== null ? pref === 'collapsed' : narrow
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const next: SidebarPref = collapsed ? 'expanded' : 'collapsed'
|
||||
setPref(next)
|
||||
|
||||
// Collapsing while focus sits in the nav leaves
|
||||
// `.sidebar:has(.nav:focus-within)` matching, which holds the panel at
|
||||
// full width — so the shortcut looks like it did nothing. Park focus on
|
||||
// the toggle instead: it is outside .nav, stays visible in the rail, and
|
||||
// is one Shift+Tab from where the user was. Blurring would drop them
|
||||
// back to the start of the document.
|
||||
if (next === 'collapsed' && navHasFocus()) {
|
||||
toggleRef.current?.focus({preventScroll: true})
|
||||
}
|
||||
}, [collapsed])
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia(NARROW_QUERY)
|
||||
const onChange = (event: MediaQueryListEvent) => setNarrow(event.matches)
|
||||
query.addEventListener('change', onChange)
|
||||
return () => query.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-sidebar', collapsed ? 'collapsed' : 'expanded')
|
||||
}, [collapsed])
|
||||
|
||||
useEffect(() => {
|
||||
if (pref === null) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, pref)
|
||||
} catch {
|
||||
// ignore persistence failures
|
||||
}
|
||||
}, [pref])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'b' && event.key !== 'B') return
|
||||
if (!event.metaKey && !event.ctrlKey) return
|
||||
if (event.altKey || event.shiftKey) return
|
||||
if (isTypingTarget(event.target)) return
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [toggle])
|
||||
|
||||
return {collapsed, toggle, toggleRef}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
function getStored(): Theme {
|
||||
const v = localStorage.getItem('floci-theme')
|
||||
return v === 'light' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(getStored)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('floci-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark')
|
||||
|
||||
return {theme, toggle}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {type ClassValue, clsx} from 'clsx'
|
||||
import {twMerge} from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatNumber(value: number | undefined): string {
|
||||
return new Intl.NumberFormat().format(value ?? 0)
|
||||
}
|
||||
|
||||
export function formatLatency(value: number | undefined): string {
|
||||
if (value == null || value <= 0) return '-'
|
||||
return value < 1000 ? `${Math.round(value)}ms` : `${(value / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
export function timeAgo(value?: string | number): string {
|
||||
if (!value) return '-'
|
||||
const ts = typeof value === 'number' ? value : new Date(value).getTime()
|
||||
if (!Number.isFinite(ts)) return '-'
|
||||
|
||||
const diff = Math.max(0, Date.now() - ts)
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
|
||||
export function statusColor(status: string): string {
|
||||
if (status === 'healthy' || status === 'active' || status === 'OK') return 'text-green-400'
|
||||
if (status === 'degraded' || status === 'INSUFFICIENT_DATA') return 'text-yellow-400'
|
||||
if (status === 'unavailable' || status === 'ALARM') return 'text-red-400'
|
||||
return 'text-[#8d9cad]'
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 3000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App/>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
import {Cloud} from 'lucide-react'
|
||||
import {Navigate, useNavigate, useParams, useSearchParams} from 'react-router-dom'
|
||||
import {CloudSelector} from '@/components/CloudSelector'
|
||||
import {
|
||||
ProviderBanner,
|
||||
RuntimeFlow,
|
||||
ServiceGrid,
|
||||
SummarySection,
|
||||
} from '@/features/cloud-console/CloudConsoleSections'
|
||||
import {useCloudConsoleHomeData} from '@/features/cloud-console/useCloudConsoleHomeData'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
|
||||
export function CloudConsoleHomePage() {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const search = (searchParams.get('search') ?? '').trim().toLowerCase()
|
||||
const routeCloud = normalizeCloud(params.cloud)
|
||||
const cloud = routeCloud ?? 'aws'
|
||||
const data = useCloudConsoleHomeData(cloud)
|
||||
|
||||
if (!routeCloud) return <Navigate to="/console/aws" replace/>
|
||||
|
||||
const filteredServices = search
|
||||
? data.serviceCards.filter((s) =>
|
||||
s.label.toLowerCase().includes(search) ||
|
||||
s.id.toLowerCase().includes(search) ||
|
||||
s.meta?.toLowerCase().includes(search)
|
||||
)
|
||||
: data.serviceCards
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header cloud-explorer-header">
|
||||
<div className="page-title">
|
||||
<Cloud size={20}/>
|
||||
<div>
|
||||
<h1>Console Home</h1>
|
||||
<p className="muted">Cloud-aware local runtime overview</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cloud-header-selectors">
|
||||
<label>
|
||||
<span>Cloud</span>
|
||||
<CloudSelector
|
||||
clouds={data.cloudsQuery.data ?? []}
|
||||
selected={cloud}
|
||||
onSelect={(nextCloud) => navigate(`/console/${nextCloud}`)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content cloud-console-home">
|
||||
<ProviderBanner
|
||||
cloud={cloud}
|
||||
runtimeClass={data.runtimeClass}
|
||||
runtimeReachable={data.status?.runtime === 'reachable'}
|
||||
onOpenStorage={() => navigate(`/cloud-explorer/${cloud}/storage`)}
|
||||
/>
|
||||
|
||||
<SummarySection
|
||||
cloud={cloud}
|
||||
runtimeLabel={data.runtimeLabel}
|
||||
runtimeState={data.runtimeState}
|
||||
runtimeClass={data.runtimeClass}
|
||||
runtimeDetail={data.runtimeDetail}
|
||||
activeServices={data.activeServices}
|
||||
activeServicesDetail={data.activeServicesDetail}
|
||||
resourceCount={data.resourceCount}
|
||||
resourceDetail={data.resourceDetail}
|
||||
/>
|
||||
|
||||
<RuntimeFlow cloud={cloud} status={data.status}/>
|
||||
|
||||
<ServiceGrid
|
||||
services={filteredServices}
|
||||
searchQuery={search}
|
||||
runtimeReachable={data.status?.runtime === 'reachable'}
|
||||
onNavigate={(route) => navigate(route)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeCloud(value?: string): CloudProvider | null {
|
||||
return value === 'aws' || value === 'azure' || value === 'gcp' ? value : null
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import {useMemo, useState} from 'react'
|
||||
import {Cloud, X} from 'lucide-react'
|
||||
import {Navigate, useNavigate, useParams} from 'react-router-dom'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {getServiceSchema} from '@/api/cloudProxyClient'
|
||||
import {useCloudServicesQuery, useCloudStatusQuery, useCloudsQuery} from '@/api/queries/cloudQueries'
|
||||
import {CloudSelector} from '@/components/CloudSelector'
|
||||
import {DynamicResourceView} from '@/components/DynamicResourceView'
|
||||
import {EmptyState} from '@/components/EmptyState'
|
||||
import {normalizeCapabilities, withRuntimeState, withServiceAvailability} from '@/lib/capabilities'
|
||||
import type {CloudProvider, CloudServiceDescriptor, CloudServiceType, CloudStatus} from '@/types/cloud'
|
||||
import type {ServiceSchema} from '@/types/schema'
|
||||
|
||||
export function CloudExplorerPage() {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const routeCloud = normalizeCloud(params.cloud)
|
||||
const cloud = routeCloud ?? 'aws'
|
||||
const service = params.service ?? ''
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
|
||||
const cloudsQuery = useCloudsQuery()
|
||||
const servicesQuery = useCloudServicesQuery(cloud)
|
||||
const statusQuery = useCloudStatusQuery(cloud)
|
||||
const schemaQuery = useQuery({
|
||||
queryKey: ['cloud-schema', cloud, service],
|
||||
queryFn: ({signal}) => getServiceSchema(cloud, service, signal),
|
||||
enabled: Boolean(service),
|
||||
})
|
||||
|
||||
const selectedService = useMemo(
|
||||
() => servicesQuery.data?.find((item) => item.service === service),
|
||||
[service, servicesQuery.data],
|
||||
)
|
||||
|
||||
// Until the catalog resolves, availability is unknown — not "coming soon".
|
||||
// Treating it as unknown keeps a registered service from flashing the
|
||||
// adapter-coming-soon notice on every load.
|
||||
const catalogPending = servicesQuery.isPending
|
||||
|
||||
if (!routeCloud) {
|
||||
return <Navigate to="/cloud-explorer/aws/storage" replace/>
|
||||
}
|
||||
|
||||
// Never redirect while the catalog is still loading: doing so silently sent
|
||||
// any unknown or slow-loading service to storage.
|
||||
if (servicesQuery.isSuccess && !selectedService) {
|
||||
return <UnknownServiceNotice cloud={cloud} service={service} services={servicesQuery.data}/>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header cloud-explorer-header">
|
||||
<div className="page-title">
|
||||
<Cloud size={20}/>
|
||||
<div>
|
||||
<h1>Cloud Explorer</h1>
|
||||
<p className="muted">Unified local runtime console</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cloud-header-selectors">
|
||||
<label>
|
||||
<span>Service</span>
|
||||
<div className="service-selector-readonly">{selectedService?.displayName ?? service}</div>
|
||||
</label>
|
||||
<label>
|
||||
<span>Cloud</span>
|
||||
<CloudSelector
|
||||
clouds={cloudsQuery.data ?? []}
|
||||
selected={cloud}
|
||||
onSelect={(nextCloud) => navigate(`/cloud-explorer/${nextCloud}/storage`)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content cloud-explorer">
|
||||
<DynamicResourceView
|
||||
cloud={cloud}
|
||||
service={service}
|
||||
serviceAvailability={selectedService?.availability}
|
||||
serviceReason={selectedService?.reason}
|
||||
cloudStatus={statusQuery.data}
|
||||
statusLoading={statusQuery.isLoading || catalogPending}
|
||||
onOpenInfo={() => setInfoOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
{infoOpen && (
|
||||
<ServiceInfoDialog
|
||||
cloud={cloud}
|
||||
service={service}
|
||||
descriptor={selectedService}
|
||||
status={statusQuery.data}
|
||||
statusLoading={statusQuery.isLoading}
|
||||
schema={schemaQuery.data}
|
||||
onClose={() => setInfoOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeCloud(value?: string): CloudProvider | null {
|
||||
return value === 'aws' || value === 'azure' || value === 'gcp' ? value : null
|
||||
}
|
||||
|
||||
function ServiceInfoDialog({
|
||||
cloud,
|
||||
service,
|
||||
descriptor,
|
||||
status,
|
||||
statusLoading,
|
||||
schema,
|
||||
onClose,
|
||||
}: {
|
||||
cloud: CloudProvider
|
||||
service: CloudServiceType
|
||||
descriptor?: CloudServiceDescriptor
|
||||
status?: CloudStatus
|
||||
statusLoading: boolean
|
||||
schema?: ServiceSchema
|
||||
onClose: () => void
|
||||
}) {
|
||||
const capabilityList = schema?.capabilities
|
||||
? withServiceAvailability(
|
||||
withRuntimeState(
|
||||
normalizeCapabilities([
|
||||
...(schema.capabilities.resourceActions ?? []),
|
||||
...(schema.capabilities.objectActions ?? []),
|
||||
...(schema.capabilities.kubernetesActions ?? []),
|
||||
...(schema.capabilities.databaseActions ?? []),
|
||||
]),
|
||||
status?.runtime === 'reachable',
|
||||
),
|
||||
descriptor?.availability ?? 'coming_soon',
|
||||
)
|
||||
: []
|
||||
const actionFallback = schema?.actions.join(', ') ?? 'Loading actions'
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="service-info-dialog" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="service-info-header">
|
||||
<div>
|
||||
<p className="eyebrow">Service Information</p>
|
||||
<h3>{schema?.displayName ?? descriptor?.displayName ?? service}</h3>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" onClick={onClose} aria-label="Close" title="Close">
|
||||
<X size={14} aria-hidden="true"/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="service-info-grid">
|
||||
<InfoCard label="Proxy API" value={`/api/clouds/${cloud}/services/${service}`} detail="Single entry point used by the UI"/>
|
||||
<InfoCard label="Service" value={descriptor?.displayName ?? service} detail={serviceAvailability(descriptor)}/>
|
||||
<InfoCard label="Runtime" value={runtimeValue(status)} detail={runtimeDetail(status, statusLoading)} state={runtimeState(status, statusLoading)}/>
|
||||
<InfoCard label="Adapter" value={adapterValue(cloud, status)} detail={adapterDetail(status, statusLoading)} state={adapterState(cloud, status)}/>
|
||||
<InfoCard label="Connection" value={connectionValue(status, statusLoading)} detail={status?.endpoint ?? 'No runtime endpoint'} state={runtimeState(status, statusLoading)}/>
|
||||
<InfoCard label="Normalized Contract" value={schema?.columns.length ? `${schema.columns.length} columns · ${schema.filters.length} filters` : 'Loading schema'} detail="Shared table and resource inspector"/>
|
||||
</div>
|
||||
<div className="service-info-section">
|
||||
<p className="eyebrow">Supported Actions</p>
|
||||
{capabilityList.length ? (
|
||||
<div className="capability-strip">
|
||||
{capabilityList.map((capability) => (
|
||||
<span
|
||||
key={capability.name}
|
||||
className={`capability-pill ${capability.status}`}
|
||||
title={capability.reason}
|
||||
>
|
||||
{capability.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="service-info-copy">{actionFallback}</p>
|
||||
)}
|
||||
</div>
|
||||
{descriptor?.reason && (
|
||||
<div className="service-info-section">
|
||||
<p className="eyebrow">Availability</p>
|
||||
<p className="service-info-copy">{descriptor.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCard({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
state,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
detail: string
|
||||
state?: 'ready' | 'pending' | 'unavailable'
|
||||
}) {
|
||||
return (
|
||||
<div className={`service-info-card ${state ?? ''}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small>{detail}</small>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function serviceAvailability(service?: CloudServiceDescriptor): string {
|
||||
if (!service) return 'Loading service schema'
|
||||
return service.availability === 'available' ? 'Schema available' : 'Coming soon'
|
||||
}
|
||||
|
||||
function runtimeValue(status?: CloudStatus): string {
|
||||
return status?.endpoint?.replace(/^https?:\/\//, '') ?? 'Unknown'
|
||||
}
|
||||
|
||||
function runtimeDetail(status?: CloudStatus, loading?: boolean): string {
|
||||
if (loading) return 'Checking runtime'
|
||||
if (!status) return 'Status unavailable'
|
||||
if (status.runtime === 'reachable') return 'Runtime reachable'
|
||||
if (status.runtime === 'unavailable') return status.error ?? 'Runtime unavailable'
|
||||
return 'Runtime coming soon'
|
||||
}
|
||||
|
||||
function runtimeState(status?: CloudStatus, loading?: boolean): 'ready' | 'pending' | 'unavailable' {
|
||||
if (loading || !status || status.runtime === 'coming_soon') return 'pending'
|
||||
return status.runtime === 'reachable' ? 'ready' : 'unavailable'
|
||||
}
|
||||
|
||||
function adapterValue(cloud: CloudProvider, status?: CloudStatus): string {
|
||||
if (cloud === 'gcp') return 'GCP Adapter'
|
||||
if (status?.adapterRegistered === false) return 'Not registered'
|
||||
return `${cloud.toUpperCase()} Adapter`
|
||||
}
|
||||
|
||||
function adapterDetail(status?: CloudStatus, loading?: boolean): string {
|
||||
if (loading) return 'Checking adapter'
|
||||
if (!status) return 'Adapter status unknown'
|
||||
return status.adapterRegistered ? 'Adapter ready' : 'Coming soon'
|
||||
}
|
||||
|
||||
function adapterState(_cloud: CloudProvider, status?: CloudStatus): 'ready' | 'pending' | 'unavailable' {
|
||||
if (!status || !status.adapterRegistered) return 'pending'
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
function connectionValue(status?: CloudStatus, loading?: boolean): string {
|
||||
if (loading) return 'Checking'
|
||||
if (!status) return 'Unknown'
|
||||
if (status.runtime === 'reachable') return 'Connected'
|
||||
if (status.runtime === 'unavailable') return 'Not connected'
|
||||
return 'Coming soon'
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* A slug that is not in the server's catalog. Previously this silently redirected
|
||||
* to storage, so a typo or a stale bookmark looked like a working page.
|
||||
*/
|
||||
function UnknownServiceNotice({
|
||||
cloud,
|
||||
service,
|
||||
services,
|
||||
}: {
|
||||
cloud: CloudProvider
|
||||
service: string
|
||||
services?: CloudServiceDescriptor[]
|
||||
}) {
|
||||
const available = (services ?? []).filter((item) => item.availability === 'available')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header cloud-explorer-header">
|
||||
<div className="page-title">
|
||||
<Cloud size={20}/>
|
||||
<div>
|
||||
<h1>Cloud Explorer</h1>
|
||||
<p className="muted">Unified local runtime console</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content cloud-explorer">
|
||||
<EmptyState
|
||||
icon={Cloud}
|
||||
title={service ? `${cloud.toUpperCase()} has no service called "${service}"` : 'No service selected'}
|
||||
description="Pick one of the services this runtime exposes."
|
||||
/>
|
||||
<div className="unknown-service-links">
|
||||
{available.map((item) => (
|
||||
<Link
|
||||
className="button compact"
|
||||
key={item.service}
|
||||
to={item.route.startsWith('/') ? item.route : `/cloud-explorer/${cloud}/${item.route}`}
|
||||
>
|
||||
{item.displayName}
|
||||
</Link>
|
||||
))}
|
||||
<Link className="button compact" to={`/console/${cloud}`}>Console Home</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {ArrowLeft, Database} from 'lucide-react'
|
||||
import {Link, useParams, useSearchParams} from 'react-router-dom'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {getCloudResource} from '@/api/cloudProxyClient'
|
||||
import {HttpError} from '@/api/HttpClient'
|
||||
import {useCloudServicesQuery, useCloudStatusQuery} from '@/api/queries/cloudQueries'
|
||||
import {AzureSqlPanel} from '@/components/AzureSqlPanel'
|
||||
import {CosmosNoSqlPanel} from '@/components/CosmosNoSqlPanel'
|
||||
import {DynamoDbTableExplorer} from '@/components/DynamoDbTableExplorer'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {dataExplorerKind} from '@/lib/dataExplorer'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
|
||||
export function DatabaseDataPage() {
|
||||
const {cloud, service, resourceId} = useParams()
|
||||
const accountId = useAccountId()
|
||||
if ((cloud !== 'aws' && cloud !== 'azure' && cloud !== 'gcp') || !service || !resourceId) {
|
||||
return <div className="content"><DataNotice title="Invalid data workspace" detail="Check the cloud, service, and resource in this link."/>
|
||||
<Link to="/console/aws">Console Home</Link></div>
|
||||
}
|
||||
return <DatabaseDataWorkspace key={`${accountId}:${cloud}:${service}:${resourceId}`} cloud={cloud} service={service} resourceId={resourceId}/>
|
||||
}
|
||||
|
||||
function DatabaseDataWorkspace({cloud, service, resourceId}: {cloud: CloudProvider; service: string; resourceId: string}) {
|
||||
const accountId = useAccountId()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const servicesQuery = useCloudServicesQuery(cloud)
|
||||
const statusQuery = useCloudStatusQuery(cloud)
|
||||
const descriptor = servicesQuery.data?.find((item) => item.service === service)
|
||||
const resourceQuery = useQuery({
|
||||
queryKey: ['cloud-data-resource', accountId, cloud, service, resourceId],
|
||||
queryFn: ({signal}) => getCloudResource(cloud, service, resourceId, signal),
|
||||
enabled: descriptor?.availability === 'available' && statusQuery.data?.runtime === 'reachable',
|
||||
})
|
||||
const resource = resourceQuery.data
|
||||
const kind = resource && dataExplorerKind(resource)
|
||||
const containerId = searchParams.get('container') || undefined
|
||||
|
||||
function retry() {
|
||||
void servicesQuery.refetch()
|
||||
void statusQuery.refetch()
|
||||
if (descriptor?.availability === 'available' && statusQuery.data?.runtime === 'reachable') void resourceQuery.refetch()
|
||||
}
|
||||
|
||||
function selectContainer(id: string | undefined) {
|
||||
setSearchParams((previous) => {
|
||||
const next = new URLSearchParams(previous)
|
||||
if (id) next.set('container', id)
|
||||
else next.delete('container')
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function renderWorkspace() {
|
||||
if (servicesQuery.isPending || statusQuery.isPending) return <DataNotice title="Loading workspace" detail="Checking service availability and runtime connection."/>
|
||||
if (servicesQuery.isError || statusQuery.isError) return <DataNotice title="Unable to load workspace" detail={(servicesQuery.error ?? statusQuery.error)?.message} onRetry={retry}/>
|
||||
if (!descriptor) return <DataNotice title="Unknown service" detail={`No ${service} service exists for ${cloud.toUpperCase()}.`}/>
|
||||
if (descriptor.availability !== 'available') return <DataNotice title="Service unavailable" detail={descriptor.reason ?? 'This service has no available adapter.'}/>
|
||||
if (statusQuery.data?.runtime !== 'reachable') return <DataNotice title="Runtime unavailable" detail={statusQuery.data?.error ?? 'Start the selected runtime to explore data.'} onRetry={retry}/>
|
||||
if (resourceQuery.isError) return <DataNotice title={resourceQuery.error instanceof HttpError && resourceQuery.error.status === 404 ? 'Resource not found' : 'Unable to load resource'} detail={resourceQuery.error.message} onRetry={retry}/>
|
||||
if (!resource) return <DataNotice title="Loading resource" detail="Reading resource details from the runtime."/>
|
||||
if (kind === 'dynamodb') return <DynamoDbTableExplorer cloud={cloud} resource={resource} runtimeReachable/>
|
||||
if (kind === 'cosmos') return <CosmosNoSqlPanel key={containerId ?? ''} cloud={cloud} resource={resource} runtimeReachable selectedContainerId={containerId} onSelectContainer={selectContainer}/>
|
||||
if (kind === 'sql') return <AzureSqlPanel cloud={cloud} resource={resource} runtimeReachable/>
|
||||
return <DataNotice title="Data exploration unavailable" detail="This resource supports management operations only."/>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="database-data-page">
|
||||
<header className="page-header data-workspace-header">
|
||||
<Link className="button compact" to={`/cloud-explorer/${cloud}/${service}`}><ArrowLeft size={14}/>Back to {descriptor?.displayName ?? service}</Link>
|
||||
<div className="page-title">
|
||||
<Database size={20}/>
|
||||
<div>
|
||||
<h1>{resource?.name ?? resourceId}</h1>
|
||||
<p className="muted">{cloud.toUpperCase()} · {descriptor?.displayName ?? service} · Explore data</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="data-workspace">{renderWorkspace()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DataNotice({title, detail, onRetry}: {title: string; detail?: string; onRetry?: () => void}) {
|
||||
return <div className="runtime-notice" role="status"><h3>{title}</h3>{detail && <p>{detail}</p>}
|
||||
{onRetry && <button className="button" type="button" onClick={onRetry}>Retry</button>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export type EventListener<TEvent> = (event: TEvent) => void;
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
export interface EventBusListenerError<TEvent> {
|
||||
error: unknown;
|
||||
event: TEvent;
|
||||
listener: EventListener<TEvent>;
|
||||
}
|
||||
|
||||
export interface EventBusOptions<TEvent> {
|
||||
onListenerError?: (error: EventBusListenerError<TEvent>) => void;
|
||||
}
|
||||
|
||||
export class EventBus<TEvent> {
|
||||
private readonly listeners = new Set<EventListener<TEvent>>();
|
||||
|
||||
constructor(private readonly options: EventBusOptions<TEvent> = {}) {}
|
||||
|
||||
subscribe(listener: EventListener<TEvent>): Unsubscribe {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
publish(event: TEvent) {
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
this.options.onListenerError?.({
|
||||
error,
|
||||
event,
|
||||
listener,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.listeners.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type ScheduledJob = {
|
||||
name: string;
|
||||
intervalMs: number;
|
||||
run: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export class Scheduler {
|
||||
private readonly timers = new Map<string, number>();
|
||||
|
||||
add(job: ScheduledJob) {
|
||||
if (this.timers.has(job.name)) {
|
||||
throw new Error(`Scheduled job "${job.name}" already exists.`);
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
void job.run();
|
||||
}, job.intervalMs);
|
||||
|
||||
this.timers.set(job.name, timer);
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const timer of this.timers.values()) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
|
||||
this.timers.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface TelemetryIngestor {
|
||||
name: string;
|
||||
flushIntervalMs: number;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
flush(): void | Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { assert } from "@/lib/assertions";
|
||||
import type { ApiRequestEvent, CloudProvider } from "./types";
|
||||
|
||||
const CLOUD_PROVIDERS: ReadonlySet<CloudProvider> = new Set([
|
||||
"aws",
|
||||
"azure",
|
||||
"gcp",
|
||||
"system",
|
||||
]);
|
||||
|
||||
export function assertApiRequestEvent(
|
||||
event: ApiRequestEvent,
|
||||
): asserts event is ApiRequestEvent {
|
||||
assertCloudProvider(event.provider);
|
||||
assertNonEmptyString(event.service, "service");
|
||||
assertNonEmptyString(event.method, "method");
|
||||
assertNonEmptyString(event.path, "path");
|
||||
assertNonNegativeFiniteNumber(event.statusCode, "statusCode");
|
||||
assertNonNegativeFiniteNumber(event.latencyMs, "latencyMs");
|
||||
assertPositiveFiniteNumber(event.timestamp, "timestamp");
|
||||
}
|
||||
|
||||
export function assertCloudProvider(
|
||||
provider: CloudProvider,
|
||||
): asserts provider is CloudProvider {
|
||||
assert(
|
||||
CLOUD_PROVIDERS.has(provider),
|
||||
`Invalid cloud provider: ${String(provider)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertNonEmptyString(value: string, field: string) {
|
||||
assert(
|
||||
typeof value === "string" && value.trim() !== "",
|
||||
`Invalid API request event "${field}": expected string.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertNonNegativeFiniteNumber(value: number, field: string) {
|
||||
assert(
|
||||
typeof value === "number" && Number.isFinite(value) && value >= 0,
|
||||
`Invalid API request event "${field}": expected non-negative number.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertPositiveFiniteNumber(value: number, field: string) {
|
||||
assert(
|
||||
typeof value === "number" && Number.isFinite(value) && value > 0,
|
||||
`Invalid API request event "${field}": expected positive number.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export { EventBus } from "./EventBus";
|
||||
export { Scheduler } from "./Scheduler";
|
||||
export {
|
||||
apiRequestEventBus,
|
||||
emitApiRequest,
|
||||
subscribeApiRequests,
|
||||
} from "./requestEventBus";
|
||||
export { assertApiRequestEvent, assertCloudProvider } from "./assertions";
|
||||
export { assert, assertDefined, assertNever } from "@/lib/assertions";
|
||||
export type { ApiRequestEvent, CloudProvider } from "./types";
|
||||
export type {
|
||||
EventBusListenerError,
|
||||
EventBusOptions,
|
||||
EventListener,
|
||||
Unsubscribe,
|
||||
} from "./EventBus";
|
||||
export type { ScheduledJob } from "./Scheduler";
|
||||
export type { TelemetryIngestor } from "./TelemetryIngestor";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user