first commit

This commit is contained in:
Zakaria
2026-09-22 16:13:21 -04:00
commit 7e49be27f1
297 changed files with 63162 additions and 0 deletions
+8
View File
@@ -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"]
+35
View File
@@ -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 },
],
},
},
);
+29
View File
@@ -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>
+53
View File
@@ -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"
}
}
+19
View File
@@ -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,
},
})
+26
View File
@@ -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>
)
}
+442
View File
@@ -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;
}
}
+965
View File
@@ -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");
},
);
});
+730
View File
@@ -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,
});
}
+260
View File
@@ -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 };
}
+63
View File
@@ -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";
+114
View File
@@ -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};
}
}
+361
View File
@@ -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 &amp; 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 &ldquo;{search}&rdquo;.</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
+102
View File
@@ -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)
}
+21
View File
@@ -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)}`);
}
+82
View File
@@ -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)
}
}
+13
View File
@@ -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`
}
+105
View File
@@ -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, '')
}
+142
View File
@@ -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 }
})
}
+28
View File
@@ -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
}
+103
View File
@@ -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}
}
+21
View File
@@ -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}
}
+37
View File
@@ -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]'
}
+22
View File
@@ -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.`,
);
}
+18
View File
@@ -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