Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5e511fbfa | |||
| d4726950da | |||
| eeadeef664 | |||
| 9f1ea32183 | |||
| 811791603c | |||
| 67660858eb |
@@ -1,26 +1,23 @@
|
||||
<p align="center">
|
||||
<img src="docs/images/floci-black.svg#gh-light-mode-only" alt="Floci UI" width="460" />
|
||||
<img src="docs/images/floci-white.svg#gh-dark-mode-only" alt="Floci UI" width="460" />
|
||||
<img src="docs/images/thecloud-logo.png" alt="TheCloud" width="160" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">TheCloud</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Any Cloud. Locally.</strong><br />
|
||||
A local-first, cloud-aware runtime console for Floci and compatible local cloud emulators.
|
||||
A self-hosted cloud console for local AWS, Azure and GCP runtimes.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/floci-io/floci-ui/releases/latest"><img src="https://img.shields.io/github/v/release/floci-io/floci-ui?label=latest%20release&color=blue" alt="Latest Release"></a>
|
||||
<a href="https://github.com/floci-io/floci-ui/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/floci-io/floci-ui/ci.yml?branch=main&label=ci" alt="CI Status"></a>
|
||||
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/pulls/floci/floci-ui?label=docker%20pulls" alt="Docker Pulls"></a>
|
||||
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/image-size/floci/floci-ui/latest?label=image%20size" alt="Docker Image Size"></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/floci-ui-console.png" alt="Floci UI console" width="900" />
|
||||
<img src="docs/images/thecloud-console.png" alt="TheCloud's Cloud Explorer showing S3 buckets and the selected bucket's details" width="900" />
|
||||
</p>
|
||||
|
||||
Floci UI is the web console for the Floci ecosystem. The current app is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`. It renders only real data returned by local runtimes and explicit placeholders for work that is not wired yet.
|
||||
TheCloud is an AWS-Console-style web console for locally running cloud runtimes. It is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`, and it renders only real data returned by the runtimes, with explicit placeholders for work that is not wired yet.
|
||||
|
||||
No fake resources, no demo rows, and no mock operational data are shown in normal mode.
|
||||
|
||||
@@ -42,9 +39,9 @@ Open [http://localhost:4500](http://localhost:4500).
|
||||
|
||||
## Exposing To The Internet
|
||||
|
||||
Floci UI requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts '<password>'`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`.
|
||||
TheCloud requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts '<password>'`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`.
|
||||
|
||||
Put only Floci UI behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind Floci core (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. Set `FLOCI_UI_TRUSTED_PROXIES` to the proxy's IP or CIDR (comma-separated for several hops, e.g. Cloudflare's published ranges when it sits in front of Nginx Proxy Manager); `X-Forwarded-For` is ignored from any other peer, and without it sign-in throttling treats every visitor as the proxy.
|
||||
Put only TheCloud's console behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind TheCloud's AWS runtime (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. Set `FLOCI_UI_TRUSTED_PROXIES` to the proxy's IP or CIDR (comma-separated for several hops, e.g. Cloudflare's published ranges when it sits in front of Nginx Proxy Manager); `X-Forwarded-For` is ignored from any other peer, and without it sign-in throttling treats every visitor as the proxy.
|
||||
|
||||
Set `FLOCI_UI_AUTH_MODE=oidc` to use an OIDC provider such as Authentik. Configure `FLOCI_UI_OIDC_ISSUER`, `FLOCI_UI_OIDC_CLIENT_ID`, `FLOCI_UI_OIDC_CLIENT_SECRET`, and `FLOCI_UI_PUBLIC_BASE_URL`; the callback is `${FLOCI_UI_PUBLIC_BASE_URL}/api/auth/oidc/callback`. OIDC mode also requires an allow-list: `FLOCI_UI_OIDC_ALLOWED_GROUPS` (matched against the `groups` claim, read from UserInfo when the ID token omits it) and/or `FLOCI_UI_OIDC_ALLOWED_EMAILS` (verified emails only). Set `FLOCI_UI_OIDC_ALLOW_ALL_USERS=true` only if every user of the identity provider should have console access.
|
||||
|
||||
@@ -61,6 +58,7 @@ cd packages/api && bun run scripts/service-matrix.ts
|
||||
| Group | Service | AWS | Azure | GCP |
|
||||
|---|---|---|---|---|
|
||||
| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No |
|
||||
| Compute | EC2 Auto Scaling / Auto Scaling | Yes (list, create, delete, inspect) | No | No |
|
||||
| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) |
|
||||
| Compute | Serverless | Yes (list, create, update, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
|
||||
| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) |
|
||||
@@ -110,13 +108,9 @@ Services marked `No` render as a disabled sidebar row whose tooltip carries the
|
||||
server-supplied reason. Adding one is a catalog row in
|
||||
`packages/api/src/cloud-spi/serviceCatalog.ts` plus an adapter — no frontend change.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/floci-ui-console-azure.png" alt="Azure console home, showing services grouped by category with per-cloud naming and coming-soon reasons" width="900" />
|
||||
</p>
|
||||
|
||||
Azure on the same build: the nav is grouped by category, `k8s Engine` is labelled
|
||||
`AKS` for this provider, and every unavailable service carries a reason — Serverless
|
||||
reads `coming soon` because the Floci-AZ runtime answers 501 for Azure Functions,
|
||||
With Azure selected, the nav is grouped the same way, `k8s Engine` is labelled
|
||||
`AKS` for that provider, and every unavailable service carries a reason: Serverless
|
||||
reads `coming soon` because the Azure runtime answers 501 for Azure Functions,
|
||||
even though an adapter is registered.
|
||||
|
||||
## Current Capability Snapshot
|
||||
@@ -159,7 +153,7 @@ All three clouds, through the unified shell.
|
||||
Current gaps:
|
||||
|
||||
- EKS and AKS are read-only. On AKS this is a runtime limit rather than a choice:
|
||||
the shipped floci-az config runs AKS unmocked with no Docker socket to start k3s
|
||||
the shipped Azure runtime (`floci-az`) config runs AKS unmocked with no Docker socket to start k3s
|
||||
with, so a created cluster never leaves `provisioningState: Failed`.
|
||||
|
||||
</details>
|
||||
@@ -169,7 +163,7 @@ Current gaps:
|
||||
|
||||
Relational and document database workflows across providers:
|
||||
|
||||
- AWS RDS: list, inspect, create, update, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). Updates use generic `PATCH /api/clouds/:cloud/services/:service/resources/:id` mapping to `ModifyDBInstance` for password rotation, IAM authentication, DB subnet group, VPC security groups, option group, and auto minor version upgrade. Instance class, storage, engine, and version are omitted from edit operations because the current local Floci RDS emulator does not support modifying them.
|
||||
- AWS RDS: list, inspect, create, update, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). Updates use generic `PATCH /api/clouds/:cloud/services/:service/resources/:id` mapping to `ModifyDBInstance` for password rotation, IAM authentication, DB subnet group, VPC security groups, option group, and auto minor version upgrade. Instance class, storage, engine, and version are omitted from edit operations because TheCloud's RDS runtime does not support modifying them yet.
|
||||
- AWS RDS Snapshots: account-scoped Snapshots tab listing DB snapshots and supporting snapshot creation.
|
||||
- Azure Cosmos DB NoSQL: database, container, and document workflows.
|
||||
- AWS DynamoDB: table management, item browsing, and Add record.
|
||||
@@ -196,8 +190,8 @@ Frontend regression tests use mocked `/api/*` responses and need no running emul
|
||||
|
||||
Current gaps:
|
||||
|
||||
- AWS RDS snapshot creation: the Cloud Proxy operation is available, but the current Floci runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list.
|
||||
- AWS RDS instance stop/start operations are not implemented in the current local Floci runtime.
|
||||
- AWS RDS snapshot creation: the Cloud Proxy operation is available, but TheCloud's runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list.
|
||||
- AWS RDS instance stop/start operations are not implemented in TheCloud's runtime yet.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -280,7 +274,7 @@ Current gaps:
|
||||
|
||||
AWS SES email capture through the unified Cloud Explorer.
|
||||
|
||||
- Lists emails actually captured by Floci SES.
|
||||
- Lists emails actually captured by TheCloud's SES.
|
||||
- Filters by subject, sender, and recipient.
|
||||
- Inspects sender, recipients, timestamp, and message type.
|
||||
- Displays HTML in a sandboxed preview, text bodies, and captured raw MIME data.
|
||||
@@ -297,10 +291,10 @@ aws ses send-email \
|
||||
--endpoint-url http://localhost:4566 \
|
||||
--from sender@example.test \
|
||||
--destination 'ToAddresses=recipient@example.test' \
|
||||
--message 'Subject={Data="Floci SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="<h1>Hello from Floci</h1><p>This should render in the SES preview.</p>",Charset=utf-8}}'
|
||||
--message 'Subject={Data="TheCloud SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="<h1>Hello from TheCloud</h1><p>This should render in the SES preview.</p>",Charset=utf-8}}'
|
||||
```
|
||||
|
||||
The email is captured by the local Floci runtime; it is not delivered externally. Open
|
||||
The email is captured by TheCloud's runtime, and is delivered externally only when an SMTP relay is configured. Open
|
||||
`/cloud-explorer/aws/email` and refresh the mailbox to inspect its Preview, Text, and
|
||||
Raw views. You can also inspect the captured messages directly with:
|
||||
|
||||
@@ -328,7 +322,7 @@ AWS and GCP, both through the unified shell.
|
||||
|
||||
Current gaps:
|
||||
|
||||
- Azure Functions is registered but the Floci-AZ runtime answers 501 NotImplemented,
|
||||
- Azure Functions is registered but the Azure runtime answers 501 NotImplemented,
|
||||
so it reports `coming_soon` with that reason rather than appearing available.
|
||||
- GCP Cloud Functions invoke is not wired yet; the capability is advertised as
|
||||
`coming_soon` instead of being silently missing.
|
||||
@@ -381,7 +375,7 @@ Current gaps:
|
||||
|
||||
## Product Direction
|
||||
|
||||
Floci UI is evolving toward a metadata-driven, cloud-aware console where one web app can render multiple local runtimes through the same shell.
|
||||
TheCloud is a metadata-driven, cloud-aware console: one web app renders multiple local runtimes through the same shell.
|
||||
|
||||
The guiding rules are:
|
||||
|
||||
@@ -393,7 +387,7 @@ The guiding rules are:
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||

|
||||
|
||||
Short implementation notes live in [docs/implementation-notes.md](docs/implementation-notes.md).
|
||||
|
||||
@@ -435,9 +429,9 @@ Browser
|
||||
|
||||
Default compose stack:
|
||||
|
||||
- `floci-ui` on `http://localhost:4500`
|
||||
- `floci-api` on `http://localhost:4501`
|
||||
- `floci` on `http://localhost:4566`
|
||||
- `floci-ui` (the console) on `http://localhost:4500`
|
||||
- `floci-api` (TheCloud's API) on `http://localhost:4501`
|
||||
- `floci` (TheCloud's AWS runtime) on `http://localhost:4566`
|
||||
|
||||
Start AWS-only:
|
||||
|
||||
@@ -467,7 +461,7 @@ Prerequisites:
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
- Bun
|
||||
- A running local runtime: Floci core, and optionally Floci-AZ / Floci-GCP
|
||||
- A running local runtime: TheCloud's AWS runtime, and optionally the Azure and GCP runtimes
|
||||
|
||||
Install dependencies:
|
||||
|
||||
@@ -483,29 +477,18 @@ cp .env.example packages/api/.env
|
||||
|
||||
Important: the API runs from `packages/api` and loads environment variables from `packages/api/.env`.
|
||||
|
||||
Start Floci AWS Core with Docker:
|
||||
Start TheCloud's AWS runtime from its repository:
|
||||
|
||||
```bash
|
||||
docker run -d --name floci \
|
||||
-p 4566:4566 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e FLOCI_DEFAULT_REGION=us-east-1 \
|
||||
-u root \
|
||||
floci/floci:latest
|
||||
```
|
||||
|
||||
Or from a local clone:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/floci-io/floci.git ../floci
|
||||
git clone https://gitea.zacksolutions.dev/Zakaria/FlociClone.git ../floci
|
||||
cd ../floci
|
||||
./mvnw clean quarkus:dev
|
||||
docker compose up -d # or ./mvnw clean quarkus:dev with JDK 25
|
||||
```
|
||||
|
||||
Optional local runtimes:
|
||||
|
||||
- Floci-AZ on `http://localhost:4577`
|
||||
- Floci-GCP on `http://localhost:4588`
|
||||
- Azure runtime (`floci-az`) on `http://localhost:4577`
|
||||
- GCP runtime (`floci-gcp`) on `http://localhost:4588`
|
||||
|
||||
Start the UI stack:
|
||||
|
||||
@@ -614,18 +597,6 @@ When adding new UI surface:
|
||||
- Keep placeholders explicit instead of inventing fake data.
|
||||
- Update this README when the visible UI surface changes.
|
||||
|
||||
## Community Projects
|
||||
|
||||
Floci UI is the first-party console, but it is not the only one. The community builds
|
||||
consoles for Floci too:
|
||||
|
||||
- [floci-dash](https://github.com/ofsazib/floci-dash) — an AWS-Console-style dashboard
|
||||
for the Floci AWS runtime built on Cloudscape Design. It ships as a single Docker image
|
||||
and includes an EC2 web terminal. It targets the AWS runtime only, while Floci UI also
|
||||
covers Azure and GCP, so pick whichever fits your stack.
|
||||
|
||||
Building something for Floci? Open a PR to add it here.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) — part of the [Floci](https://floci.io) ecosystem.
|
||||
[MIT](LICENSE). TheCloud builds on the open-source [Floci](https://floci.io) project.
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
<?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>
|
||||
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="glow"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
|
||||
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #151515;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #5559A7;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #151515;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #262425;
|
||||
}
|
||||
|
||||
#logoBuild {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: logoBreath 5s ease-in-out 2.7s infinite;
|
||||
}
|
||||
|
||||
.logo-piece {
|
||||
opacity: 0;
|
||||
animation: reveal .5s ease forwards;
|
||||
}
|
||||
|
||||
.cloud-a { animation-delay: .55s; }
|
||||
.cloud-b { animation-delay: .75s; }
|
||||
.word-a { animation-delay: 1.72s; }
|
||||
.word-b { animation-delay: 1.92s; }
|
||||
.word-c { animation-delay: 2.12s; }
|
||||
.word-d { animation-delay: 2.32s; }
|
||||
|
||||
.guide-path {
|
||||
fill: none;
|
||||
opacity: 1;
|
||||
stroke-opacity: .9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
|
||||
guideFade .42s ease forwards;
|
||||
}
|
||||
|
||||
.guide-dark {
|
||||
stroke: #151515;
|
||||
stroke-width: 2.6;
|
||||
}
|
||||
|
||||
.guide-word {
|
||||
stroke: #262425;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.guide-cloud-a { animation-delay: .08s, 1.25s; }
|
||||
.guide-cloud-b { animation-delay: .24s, 1.45s; }
|
||||
.guide-word-a { animation-delay: 1.18s, 2.28s; }
|
||||
.guide-word-b { animation-delay: 1.36s, 2.48s; }
|
||||
.guide-word-c { animation-delay: 1.54s, 2.68s; }
|
||||
.guide-word-d { animation-delay: 1.72s, 2.88s; }
|
||||
|
||||
.dot-pop {
|
||||
opacity: 0;
|
||||
filter: url(#logoGlow);
|
||||
transform: scale(.15);
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
|
||||
softPulse 3.2s ease-in-out 2.2s infinite;
|
||||
}
|
||||
|
||||
.dot-cloud { animation-delay: 1.45s, 3.1s; }
|
||||
.dot-i { animation-delay: 2.62s, 3.45s; }
|
||||
|
||||
.cloud-shadow {
|
||||
opacity: 0;
|
||||
animation: shadowIn .8s ease 1.2s forwards;
|
||||
}
|
||||
|
||||
.shine {
|
||||
opacity: 0;
|
||||
mix-blend-mode: screen;
|
||||
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes drawLine {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes guideFade {
|
||||
from { opacity: 1; stroke-opacity: .9; }
|
||||
to { opacity: 0; stroke-opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes reveal {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes dotPop {
|
||||
0% { opacity: 0; transform: scale(.15); }
|
||||
62% { opacity: 1; transform: scale(1.18); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes softPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.08); }
|
||||
}
|
||||
|
||||
@keyframes shadowIn {
|
||||
from { opacity: 0; transform: translateY(4px) scale(.94); }
|
||||
to { opacity: .14; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes shineSweep {
|
||||
0%, 18% { opacity: 0; transform: translateX(-220px); }
|
||||
30% { opacity: .7; }
|
||||
54% { opacity: 0; transform: translateX(560px); }
|
||||
100% { opacity: 0; transform: translateX(560px); }
|
||||
}
|
||||
|
||||
@keyframes logoBreath {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.008); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#logoBuild,
|
||||
#logoBuild *,
|
||||
.shine {
|
||||
animation: none !important;
|
||||
opacity: 1;
|
||||
stroke-dashoffset: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.guide-path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.shine {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="logoBuild">
|
||||
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
|
||||
<g id="finalWordmark" class="st2">
|
||||
<g class="st2">
|
||||
<path class="st4 logo-piece word-a" 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 logo-piece word-b" 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 logo-piece word-c" 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 logo-piece word-d" 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 id="finalMark">
|
||||
<path class="st0 logo-piece cloud-a" 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-.01ZM44.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 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
|
||||
</g>
|
||||
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
|
||||
<g id="drawGuides">
|
||||
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" 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="guide-path guide-dark guide-cloud-b" pathLength="1" 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"/>
|
||||
<path class="guide-path guide-word guide-word-a" pathLength="1" 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="guide-path guide-word guide-word-b" pathLength="1" 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="guide-path guide-word guide-word-c" pathLength="1" 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="guide-path guide-word guide-word-d" pathLength="1" 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>
|
||||
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 119 KiB |
@@ -1,200 +0,0 @@
|
||||
<?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>
|
||||
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="glow"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
|
||||
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #5559A7;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
#logoBuild {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: logoBreath 5s ease-in-out 2.7s infinite;
|
||||
}
|
||||
|
||||
.logo-piece {
|
||||
opacity: 0;
|
||||
animation: reveal .5s ease forwards;
|
||||
}
|
||||
|
||||
.cloud-a { animation-delay: .55s; }
|
||||
.cloud-b { animation-delay: .75s; }
|
||||
.word-a { animation-delay: 1.72s; }
|
||||
.word-b { animation-delay: 1.92s; }
|
||||
.word-c { animation-delay: 2.12s; }
|
||||
.word-d { animation-delay: 2.32s; }
|
||||
|
||||
.guide-path {
|
||||
fill: none;
|
||||
opacity: 1;
|
||||
stroke-opacity: .9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
|
||||
guideFade .42s ease forwards;
|
||||
}
|
||||
|
||||
.guide-dark {
|
||||
stroke: #FFFFFF;
|
||||
stroke-width: 2.6;
|
||||
}
|
||||
|
||||
.guide-word {
|
||||
stroke: #FFFFFF;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.guide-cloud-a { animation-delay: .08s, 1.25s; }
|
||||
.guide-cloud-b { animation-delay: .24s, 1.45s; }
|
||||
.guide-word-a { animation-delay: 1.18s, 2.28s; }
|
||||
.guide-word-b { animation-delay: 1.36s, 2.48s; }
|
||||
.guide-word-c { animation-delay: 1.54s, 2.68s; }
|
||||
.guide-word-d { animation-delay: 1.72s, 2.88s; }
|
||||
|
||||
.dot-pop {
|
||||
opacity: 0;
|
||||
filter: url(#logoGlow);
|
||||
transform: scale(.15);
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
|
||||
softPulse 3.2s ease-in-out 2.2s infinite;
|
||||
}
|
||||
|
||||
.dot-cloud { animation-delay: 1.45s, 3.1s; }
|
||||
.dot-i { animation-delay: 2.62s, 3.45s; }
|
||||
|
||||
.cloud-shadow {
|
||||
opacity: 0;
|
||||
animation: shadowIn .8s ease 1.2s forwards;
|
||||
}
|
||||
|
||||
.shine {
|
||||
opacity: 0;
|
||||
mix-blend-mode: screen;
|
||||
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes drawLine {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes guideFade {
|
||||
from { opacity: 1; stroke-opacity: .9; }
|
||||
to { opacity: 0; stroke-opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes reveal {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes dotPop {
|
||||
0% { opacity: 0; transform: scale(.15); }
|
||||
62% { opacity: 1; transform: scale(1.18); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes softPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.08); }
|
||||
}
|
||||
|
||||
@keyframes shadowIn {
|
||||
from { opacity: 0; transform: translateY(4px) scale(.94); }
|
||||
to { opacity: .14; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes shineSweep {
|
||||
0%, 18% { opacity: 0; transform: translateX(-220px); }
|
||||
30% { opacity: .7; }
|
||||
54% { opacity: 0; transform: translateX(560px); }
|
||||
100% { opacity: 0; transform: translateX(560px); }
|
||||
}
|
||||
|
||||
@keyframes logoBreath {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.008); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#logoBuild,
|
||||
#logoBuild *,
|
||||
.shine {
|
||||
animation: none !important;
|
||||
opacity: 1;
|
||||
stroke-dashoffset: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.guide-path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.shine {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="logoBuild">
|
||||
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
|
||||
<g id="finalWordmark" class="st2">
|
||||
<g class="st2">
|
||||
<path class="st4 logo-piece word-a" 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 logo-piece word-b" 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 logo-piece word-c" 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 logo-piece word-d" 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 id="finalMark">
|
||||
<path class="st0 logo-piece cloud-a" 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-.01ZM44.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 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
|
||||
</g>
|
||||
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
|
||||
<g id="drawGuides">
|
||||
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" 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="guide-path guide-dark guide-cloud-b" pathLength="1" 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"/>
|
||||
<path class="guide-path guide-word guide-word-a" pathLength="1" 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="guide-path guide-word guide-word-b" pathLength="1" 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="guide-path guide-word guide-word-c" pathLength="1" 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="guide-path guide-word guide-word-d" pathLength="1" 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>
|
||||
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,72 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 620" width="960" height="620" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" role="img" aria-labelledby="title desc">
|
||||
<title id="title">TheCloud architecture</title>
|
||||
<desc id="desc">The browser talks only to TheCloud's API, which dispatches each request through one proxy service and an adapter registry to a provider adapter, which calls the local AWS, Azure or GCP runtime with that cloud's real API.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#5b6473"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="960" height="620" rx="16" fill="#f7f4ee"/>
|
||||
|
||||
<text x="480" y="44" text-anchor="middle" font-size="24" font-weight="700" fill="#1d2330">TheCloud architecture</text>
|
||||
<text x="480" y="70" text-anchor="middle" font-size="14" fill="#5b6473">One console for local AWS, Azure and GCP runtimes, using each cloud's real API</text>
|
||||
|
||||
<!-- Browser -->
|
||||
<rect x="250" y="96" width="460" height="74" rx="10" fill="#ffffff" stroke="#c9b48a" stroke-width="2"/>
|
||||
<text x="270" y="124" font-size="16" font-weight="700" fill="#1d2330">Console (React, port 4500)</text>
|
||||
<text x="270" y="148" font-size="13" fill="#5b6473">Nav and pages rendered from each service's schema; calls only /api/*</text>
|
||||
|
||||
<line x1="480" y1="170" x2="480" y2="202" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- API -->
|
||||
<rect x="160" y="206" width="640" height="186" rx="10" fill="#ffffff" stroke="#1d2330" stroke-width="2"/>
|
||||
<text x="180" y="234" font-size="16" font-weight="700" fill="#1d2330">TheCloud API (Bun and Hono, port 4501)</text>
|
||||
<text x="180" y="256" font-size="13" fill="#5b6473">Sign-in, then the generic /api/clouds/:cloud/services/:service routes</text>
|
||||
|
||||
<rect x="180" y="272" width="280" height="50" rx="8" fill="#efe7d6"/>
|
||||
<text x="196" y="294" font-size="14" font-weight="600" fill="#1d2330">CloudProxyService</text>
|
||||
<text x="196" y="312" font-size="12" fill="#5b6473">One dispatcher for every request</text>
|
||||
|
||||
<rect x="500" y="272" width="280" height="50" rx="8" fill="#efe7d6"/>
|
||||
<text x="516" y="294" font-size="14" font-weight="600" fill="#1d2330">Adapter registry and service catalog</text>
|
||||
<text x="516" y="312" font-size="12" fill="#5b6473">Registered adapters decide what is shown</text>
|
||||
|
||||
<line x1="460" y1="297" x2="496" y2="297" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<text x="180" y="350" font-size="13" fill="#1d2330"><tspan font-weight="600">Schemas</tspan> describe fields, columns and capabilities; a capability</text>
|
||||
<text x="180" y="370" font-size="13" fill="#1d2330">is only advertised when its adapter can perform it (checked by tests).</text>
|
||||
|
||||
<line x1="240" y1="392" x2="200" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<line x1="480" y1="392" x2="480" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<line x1="720" y1="392" x2="760" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- Adapters -->
|
||||
<rect x="60" y="442" width="270" height="60" rx="10" fill="#fff3e3" stroke="#e08a2c" stroke-width="2"/>
|
||||
<text x="76" y="468" font-size="15" font-weight="700" fill="#a85a0c">AWS adapters</text>
|
||||
<text x="76" y="488" font-size="12" fill="#5b6473">AWS SDK v3, one per service</text>
|
||||
|
||||
<rect x="345" y="442" width="270" height="60" rx="10" fill="#e8f0fd" stroke="#2f6fd6" stroke-width="2"/>
|
||||
<text x="361" y="468" font-size="15" font-weight="700" fill="#1f4fa3">Azure adapters</text>
|
||||
<text x="361" y="488" font-size="12" fill="#5b6473">Azure runtime client</text>
|
||||
|
||||
<rect x="630" y="442" width="270" height="60" rx="10" fill="#e8f6ec" stroke="#2f9a55" stroke-width="2"/>
|
||||
<text x="646" y="468" font-size="15" font-weight="700" fill="#1f7140">GCP adapters</text>
|
||||
<text x="646" y="488" font-size="12" fill="#5b6473">GCP runtime client</text>
|
||||
|
||||
<line x1="195" y1="502" x2="195" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<line x1="480" y1="502" x2="480" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<line x1="765" y1="502" x2="765" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- Runtimes -->
|
||||
<rect x="60" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
|
||||
<text x="76" y="558" font-size="14" font-weight="700" fill="#f7f4ee">TheCloud AWS runtime</text>
|
||||
<text x="76" y="578" font-size="12" fill="#c9cfd8">port 4566</text>
|
||||
|
||||
<rect x="345" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
|
||||
<text x="361" y="558" font-size="14" font-weight="700" fill="#f7f4ee">Azure runtime</text>
|
||||
<text x="361" y="578" font-size="12" fill="#c9cfd8">port 4577, optional</text>
|
||||
|
||||
<rect x="630" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
|
||||
<text x="646" y="558" font-size="14" font-weight="700" fill="#f7f4ee">GCP runtime</text>
|
||||
<text x="646" y="578" font-size="12" fill="#c9cfd8">port 4588, optional</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 91 KiB |
@@ -13,6 +13,7 @@
|
||||
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
||||
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
|
||||
"@aws-sdk/client-appconfig": "^3.1076.0",
|
||||
"@aws-sdk/client-auto-scaling": "^3.1146.0",
|
||||
"@aws-sdk/client-cloudformation": "^3.1111.0",
|
||||
"@aws-sdk/client-cloudfront": "^3.1146.0",
|
||||
"@aws-sdk/client-cloudwatch": "^3.1076.0",
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
type AutoScalingClient,
|
||||
CreateAutoScalingGroupCommand,
|
||||
DeleteAutoScalingGroupCommand,
|
||||
DescribeAutoScalingGroupsCommand,
|
||||
DescribeScalingActivitiesCommand,
|
||||
TerminateInstanceInAutoScalingGroupCommand,
|
||||
UpdateAutoScalingGroupCommand,
|
||||
} from '@aws-sdk/client-auto-scaling'
|
||||
import {DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||
import {AwsAutoScalingAdapter} from './AwsAutoScalingAdapter'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stub(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const sent: object[] = []
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
return handler(command)
|
||||
},
|
||||
}
|
||||
return {client, sent}
|
||||
}
|
||||
|
||||
const group = {
|
||||
AutoScalingGroupName: 'web-asg',
|
||||
AutoScalingGroupARN: 'arn:aws:autoscaling:us-east-1:000000000000:autoScalingGroup:web-asg',
|
||||
LaunchTemplate: {LaunchTemplateName: 'web-lt', Version: '$Latest'},
|
||||
MinSize: 1, MaxSize: 3, DesiredCapacity: 2,
|
||||
VPCZoneIdentifier: 'subnet-a,subnet-b',
|
||||
HealthCheckType: 'EC2',
|
||||
Instances: [
|
||||
{InstanceId: 'i-b8736c8b4a8c129a3', AvailabilityZone: 'us-east-1a', LifecycleState: 'InService', HealthStatus: 'Healthy', InstanceType: 't3.micro'},
|
||||
{InstanceId: 'i-a3155c876867976b1', AvailabilityZone: 'us-east-1b', LifecycleState: 'Pending', HealthStatus: 'Healthy', InstanceType: 't3.micro'},
|
||||
],
|
||||
CreatedTime: new Date('2026-10-25T16:49:54Z'),
|
||||
}
|
||||
const activity = {ActivityId: 'a1', Description: 'Launching a new EC2 instance: i-b8736c8b4a8c129a3', Cause: 'An instance was started in response to a desired capacity change.', StatusCode: 'Successful', StartTime: new Date('2026-10-25T16:50:09Z')}
|
||||
|
||||
function adapterWith(handler: (command: object) => SendResult | undefined) {
|
||||
const autoscaling = stub((command) => handler(command) ?? (
|
||||
command instanceof DescribeAutoScalingGroupsCommand ? {AutoScalingGroups: [group]}
|
||||
: command instanceof DescribeScalingActivitiesCommand ? {Activities: [activity]}
|
||||
: {}))
|
||||
const ec2 = stub((command) => command instanceof DescribeSubnetsCommand ? {Subnets: [{SubnetId: 'subnet-default-b'}, {SubnetId: 'subnet-default-a'}]} : {})
|
||||
return {
|
||||
sent: autoscaling.sent,
|
||||
ec2Sent: ec2.sent,
|
||||
adapter: new AwsAutoScalingAdapter(autoscaling.client as unknown as AutoScalingClient, ec2.client as unknown as EC2Client),
|
||||
}
|
||||
}
|
||||
|
||||
describe('AwsAutoScalingAdapter', () => {
|
||||
test('lists groups across pages with their capacity and template', async () => {
|
||||
const {adapter} = adapterWith((command) => {
|
||||
if (!(command instanceof DescribeAutoScalingGroupsCommand)) return undefined
|
||||
return command.input.NextToken
|
||||
? {AutoScalingGroups: [{...group, AutoScalingGroupName: 'jobs', LaunchTemplate: {LaunchTemplateName: 'jobs-lt'}, Instances: []}]}
|
||||
: {AutoScalingGroups: [group], NextToken: 'n'}
|
||||
})
|
||||
|
||||
const groups = await adapter.list()
|
||||
expect(groups.map((g) => [g.name, g.metadata.capacity, g.metadata.desiredCapacity, g.metadata.launchTemplateName])).toEqual([
|
||||
['web-asg', '1 in service', 2, 'web-lt'],
|
||||
['jobs', '0 in service', 2, 'jobs-lt'],
|
||||
])
|
||||
expect((await adapter.list({search: 'jobs-lt'})).map((g) => g.name)).toEqual(['jobs'])
|
||||
})
|
||||
|
||||
test('describes the instances and recent scaling activity', async () => {
|
||||
const {adapter} = adapterWith(() => undefined)
|
||||
const detail = await adapter.get('web-asg')
|
||||
expect(detail?.metadata.instances).toEqual([
|
||||
{id: 'i-b8736c8b4a8c129a3', availabilityZone: 'us-east-1a', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro', protectedFromScaleIn: false},
|
||||
{id: 'i-a3155c876867976b1', availabilityZone: 'us-east-1b', lifecycleState: 'Pending', health: 'Healthy', instanceType: 't3.micro', protectedFromScaleIn: false},
|
||||
])
|
||||
expect(detail?.metadata.activities).toEqual([{id: 'a1', description: activity.Description, cause: activity.Cause, status: 'Successful', statusMessage: null, startTime: '2026-10-25T16:50:09.000Z'}])
|
||||
})
|
||||
|
||||
test('keeps the group readable when its activities cannot be read', async () => {
|
||||
const {adapter} = adapterWith((command) => {
|
||||
if (command instanceof DescribeScalingActivitiesCommand) throw new Error('throttled')
|
||||
return undefined
|
||||
})
|
||||
expect((await adapter.get('web-asg'))?.metadata.activitiesUnavailable).toBe(true)
|
||||
})
|
||||
|
||||
test('a missing group is null', async () => {
|
||||
const {adapter} = adapterWith((command) => command instanceof DescribeAutoScalingGroupsCommand ? {AutoScalingGroups: []} : undefined)
|
||||
expect(await adapter.get('nope')).toBeNull()
|
||||
})
|
||||
|
||||
test('creates a group in the default subnets when none are given', async () => {
|
||||
const {adapter, sent} = adapterWith(() => undefined)
|
||||
|
||||
await adapter.create({values: {name: 'web-asg', launchTemplate: 'web-lt', minSize: '1', desiredCapacity: '2', maxSize: '3'}})
|
||||
expect((sent[0] as CreateAutoScalingGroupCommand).input).toEqual({
|
||||
AutoScalingGroupName: 'web-asg',
|
||||
LaunchTemplate: {LaunchTemplateName: 'web-lt', Version: '$Latest'},
|
||||
MinSize: 1, MaxSize: 3, DesiredCapacity: 2,
|
||||
VPCZoneIdentifier: 'subnet-default-a,subnet-default-b',
|
||||
HealthCheckType: 'EC2',
|
||||
})
|
||||
})
|
||||
|
||||
test('creates from a template ID with given subnets, target groups and ELB health checks', async () => {
|
||||
const {adapter, sent, ec2Sent} = adapterWith(() => undefined)
|
||||
const targetGroup = 'arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/web/abc'
|
||||
|
||||
await adapter.create({values: {name: 'web-asg', launchTemplate: 'lt-ba034c5aa8346f602', launchTemplateVersion: '2', minSize: '0', maxSize: '2', subnets: 'subnet-a, subnet-b', targetGroupArns: targetGroup, healthCheckType: 'ELB'}})
|
||||
expect((sent[0] as CreateAutoScalingGroupCommand).input).toMatchObject({
|
||||
LaunchTemplate: {LaunchTemplateId: 'lt-ba034c5aa8346f602', Version: '2'},
|
||||
DesiredCapacity: 0,
|
||||
VPCZoneIdentifier: 'subnet-a,subnet-b',
|
||||
TargetGroupARNs: [targetGroup],
|
||||
HealthCheckType: 'ELB',
|
||||
HealthCheckGracePeriod: 300,
|
||||
})
|
||||
expect(ec2Sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('refuses capacities AWS would reject, before calling it', async () => {
|
||||
const {adapter, sent} = adapterWith(() => undefined)
|
||||
const base = {name: 'g', launchTemplate: 'web-lt'}
|
||||
await expect(adapter.create({values: {...base, minSize: '3', maxSize: '1'}})).rejects.toThrow('minSize cannot be larger than maxSize')
|
||||
await expect(adapter.create({values: {...base, minSize: '1', desiredCapacity: '5', maxSize: '3'}})).rejects.toThrow('desiredCapacity must be between')
|
||||
await expect(adapter.create({values: {...base, minSize: '0', maxSize: '500'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {...base, minSize: '', maxSize: '1'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {...base, minSize: '1', maxSize: '1', launchTemplateVersion: 'latest'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
await expect(adapter.create({values: {...base, name: 'a:b', minSize: '1', maxSize: '1'}})).rejects.toBeInstanceOf(ValidationError)
|
||||
expect(sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('changes capacity, keeping the values not given', async () => {
|
||||
const {adapter, sent} = adapterWith(() => undefined)
|
||||
|
||||
await adapter.setAutoScalingCapacity('web-asg', {desiredCapacity: 3})
|
||||
expect((sent.find((command) => command instanceof UpdateAutoScalingGroupCommand) as UpdateAutoScalingGroupCommand).input).toEqual({AutoScalingGroupName: 'web-asg', MinSize: 1, DesiredCapacity: 3, MaxSize: 3})
|
||||
await expect(adapter.setAutoScalingCapacity('web-asg', {desiredCapacity: 4})).rejects.toThrow('desiredCapacity must be between')
|
||||
})
|
||||
|
||||
test('terminates an instance of the group, replacing it or shrinking the group', async () => {
|
||||
const {adapter, sent} = adapterWith(() => undefined)
|
||||
|
||||
await adapter.terminateAutoScalingInstance('web-asg', 'i-b8736c8b4a8c129a3', true)
|
||||
await adapter.terminateAutoScalingInstance('web-asg', 'i-b8736c8b4a8c129a3', false)
|
||||
const terminations = sent.filter((command): command is TerminateInstanceInAutoScalingGroupCommand => command instanceof TerminateInstanceInAutoScalingGroupCommand)
|
||||
expect(terminations.map((command) => command.input.ShouldDecrementDesiredCapacity)).toEqual([false, true])
|
||||
await expect(adapter.terminateAutoScalingInstance('web-asg', 'i-0000000000000000', true)).rejects.toBeInstanceOf(NotFoundError)
|
||||
})
|
||||
|
||||
test('deletes a group together with its instances', async () => {
|
||||
const {adapter, sent} = adapterWith(() => undefined)
|
||||
await adapter.delete('web-asg')
|
||||
expect((sent[0] as DeleteAutoScalingGroupCommand).input).toEqual({AutoScalingGroupName: 'web-asg', ForceDelete: true})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
type Activity,
|
||||
type AutoScalingClient,
|
||||
type AutoScalingGroup,
|
||||
CreateAutoScalingGroupCommand,
|
||||
DeleteAutoScalingGroupCommand,
|
||||
DescribeAutoScalingGroupsCommand,
|
||||
DescribeScalingActivitiesCommand,
|
||||
TerminateInstanceInAutoScalingGroupCommand,
|
||||
UpdateAutoScalingGroupCommand,
|
||||
} from '@aws-sdk/client-auto-scaling'
|
||||
import {DescribeSubnetsCommand, type EC2Client} from '@aws-sdk/client-ec2'
|
||||
import {AUTO_SCALING_GROUP_NAME_PATTERN, AUTO_SCALING_MAX_INSTANCES, awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
|
||||
import {NotFoundError, RuntimeError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {AutoScalingCapacityInput, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
/** The most recent activities a group's detail shows. */
|
||||
const ACTIVITY_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Talks to EC2 Auto Scaling, verified against Floci core (community, `latest`).
|
||||
*
|
||||
* Notes from probing the runtime:
|
||||
* - Groups really scale: a reconciler runs every 10 seconds and launches or
|
||||
* terminates EC2 instances (containers) until the group matches its desired
|
||||
* capacity, registering them with the group's target groups, and records each
|
||||
* launch and termination as a scaling activity.
|
||||
* - Scaling policies are stored but never run: the runtime's alarms drive only
|
||||
* Application Auto Scaling (ECS services), so this adapter does not offer them.
|
||||
*
|
||||
* Groups are keyed by name, which is unique per region.
|
||||
*/
|
||||
export class AwsAutoScalingAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'autoscaling' as const
|
||||
|
||||
constructor(private readonly autoscaling: AutoScalingClient, private readonly ec2: EC2Client) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsAutoScalingSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const groups: AutoScalingGroup[] = []
|
||||
let next: string | undefined
|
||||
do {
|
||||
const res = await this.autoscaling.send(new DescribeAutoScalingGroupsCommand({MaxRecords: 100, ...(next ? {NextToken: next} : {})}))
|
||||
groups.push(...(res.AutoScalingGroups ?? []))
|
||||
next = res.NextToken
|
||||
} while (next)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return groups.map((group) => toResource(group)).filter((group) => !search
|
||||
|| group.name.toLowerCase().includes(search)
|
||||
|| String(group.metadata.launchTemplateName ?? '').toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
const group = await this.describe(id)
|
||||
if (!group) return null
|
||||
return toResource(group, await this.activities(id))
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const values = input.values
|
||||
const name = text(values.name)
|
||||
if (!new RegExp(AUTO_SCALING_GROUP_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 255 characters, without a colon')
|
||||
|
||||
const template = text(values.launchTemplate)
|
||||
if (!template) throw new ValidationError('launchTemplate is required')
|
||||
const version = text(values.launchTemplateVersion) || '$Latest'
|
||||
if (!/^(\d+|\$Latest|\$Default)$/.test(version)) throw new ValidationError('launchTemplateVersion must be a version number, $Latest or $Default')
|
||||
|
||||
const minSize = count(values.minSize, 'minSize')
|
||||
const maxSize = count(values.maxSize, 'maxSize')
|
||||
const desiredCapacity = text(values.desiredCapacity) ? count(values.desiredCapacity, 'desiredCapacity') : minSize
|
||||
checkOrder({minSize, desiredCapacity, maxSize})
|
||||
|
||||
const subnets = text(values.subnets).split(',').map((subnet) => subnet.trim()).filter(Boolean)
|
||||
if (subnets.some((subnet) => !/^subnet-[\w-]+$/.test(subnet))) throw new ValidationError('subnets must be subnet IDs separated by commas')
|
||||
const targetGroupArns = text(values.targetGroupArns).split(/\s+/).filter(Boolean)
|
||||
if (targetGroupArns.some((arn) => !/^arn:aws[a-z-]*:elasticloadbalancing:.+:targetgroup\/.+$/.test(arn))) {
|
||||
throw new ValidationError('targetGroupArns must be target group ARNs, one per line')
|
||||
}
|
||||
const healthCheckType = values.healthCheckType === 'ELB' ? 'ELB' : 'EC2'
|
||||
|
||||
await this.autoscaling.send(new CreateAutoScalingGroupCommand({
|
||||
AutoScalingGroupName: name,
|
||||
LaunchTemplate: {...(template.startsWith('lt-') ? {LaunchTemplateId: template} : {LaunchTemplateName: template}), Version: version},
|
||||
MinSize: minSize,
|
||||
MaxSize: maxSize,
|
||||
DesiredCapacity: desiredCapacity,
|
||||
VPCZoneIdentifier: (subnets.length > 0 ? subnets : await this.defaultSubnets()).join(','),
|
||||
...(targetGroupArns.length > 0 ? {TargetGroupARNs: targetGroupArns} : {}),
|
||||
HealthCheckType: healthCheckType,
|
||||
// AWS's default grace period when a load balancer decides health.
|
||||
...(healthCheckType === 'ELB' ? {HealthCheckGracePeriod: 300} : {}),
|
||||
}))
|
||||
|
||||
const created = await this.get(name)
|
||||
if (!created) throw new RuntimeError(`EC2 Auto Scaling did not return the new group ${name}`)
|
||||
return created
|
||||
}
|
||||
|
||||
/** ForceDelete terminates the group's instances too, which is what deleting a group in the console means. */
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.autoscaling.send(new DeleteAutoScalingGroupCommand({AutoScalingGroupName: id, ForceDelete: true}))
|
||||
}
|
||||
|
||||
async setAutoScalingCapacity(id: string, input: AutoScalingCapacityInput): Promise<CloudResource> {
|
||||
const group = await this.describe(id)
|
||||
if (!group) throw new NotFoundError(`Auto Scaling group ${id} was not found`)
|
||||
|
||||
const next = {
|
||||
minSize: input.minSize === undefined ? group.MinSize ?? 0 : count(input.minSize, 'minSize'),
|
||||
desiredCapacity: input.desiredCapacity === undefined ? group.DesiredCapacity ?? 0 : count(input.desiredCapacity, 'desiredCapacity'),
|
||||
maxSize: input.maxSize === undefined ? group.MaxSize ?? 0 : count(input.maxSize, 'maxSize'),
|
||||
}
|
||||
checkOrder(next)
|
||||
|
||||
await this.autoscaling.send(new UpdateAutoScalingGroupCommand({
|
||||
AutoScalingGroupName: id,
|
||||
MinSize: next.minSize,
|
||||
DesiredCapacity: next.desiredCapacity,
|
||||
MaxSize: next.maxSize,
|
||||
}))
|
||||
const updated = await this.get(id)
|
||||
if (!updated) throw new NotFoundError(`Auto Scaling group ${id} was not found`)
|
||||
return updated
|
||||
}
|
||||
|
||||
async terminateAutoScalingInstance(id: string, instanceId: string, replace: boolean): Promise<void> {
|
||||
if (!/^i-[0-9a-f]+$/.test(instanceId)) throw new ValidationError('instanceId must be an EC2 instance ID such as i-0abc1234')
|
||||
const group = await this.describe(id)
|
||||
if (!group?.Instances?.some((instance) => instance.InstanceId === instanceId)) {
|
||||
throw new NotFoundError(`Instance ${instanceId} is not in Auto Scaling group ${id}`)
|
||||
}
|
||||
await this.autoscaling.send(new TerminateInstanceInAutoScalingGroupCommand({InstanceId: instanceId, ShouldDecrementDesiredCapacity: !replace}))
|
||||
}
|
||||
|
||||
private async describe(name: string): Promise<AutoScalingGroup | null> {
|
||||
const res = await this.autoscaling.send(new DescribeAutoScalingGroupsCommand({AutoScalingGroupNames: [name]}))
|
||||
return res.AutoScalingGroups?.find((group) => group.AutoScalingGroupName === name) ?? null
|
||||
}
|
||||
|
||||
/** Activities are enrichment: a failure leaves the group readable rather than failing the request. */
|
||||
private async activities(name: string): Promise<Activity[] | 'unavailable'> {
|
||||
try {
|
||||
const res = await this.autoscaling.send(new DescribeScalingActivitiesCommand({AutoScalingGroupName: name, MaxRecords: ACTIVITY_LIMIT}))
|
||||
return res.Activities ?? []
|
||||
} catch {
|
||||
return 'unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
/** AWS needs subnets or zones; like its console, fall back to the default VPC's subnets. */
|
||||
private async defaultSubnets(): Promise<string[]> {
|
||||
const res = await this.ec2.send(new DescribeSubnetsCommand({Filters: [{Name: 'default-for-az', Values: ['true']}]}))
|
||||
const subnets = (res.Subnets ?? []).map((subnet) => subnet.SubnetId).filter((id): id is string => Boolean(id)).sort()
|
||||
if (subnets.length === 0) throw new ValidationError('There is no default VPC to launch into; enter subnet IDs')
|
||||
return subnets
|
||||
}
|
||||
}
|
||||
|
||||
function toResource(group: AutoScalingGroup, activities?: Activity[] | 'unavailable'): CloudResource {
|
||||
const name = group.AutoScalingGroupName ?? ''
|
||||
const instances = group.Instances ?? []
|
||||
const template = group.LaunchTemplate ?? group.MixedInstancesPolicy?.LaunchTemplate?.LaunchTemplateSpecification
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'aws',
|
||||
service: 'autoscaling',
|
||||
type: 'autoScalingGroup',
|
||||
region: group.AutoScalingGroupARN?.split(':')[3] || null,
|
||||
createdAt: group.CreatedTime ? group.CreatedTime.toISOString() : null,
|
||||
status: group.Status ?? null,
|
||||
metadata: {
|
||||
arn: group.AutoScalingGroupARN,
|
||||
minSize: group.MinSize ?? 0,
|
||||
maxSize: group.MaxSize ?? 0,
|
||||
desiredCapacity: group.DesiredCapacity ?? 0,
|
||||
capacity: `${instances.filter((instance) => instance.LifecycleState === 'InService').length} in service`,
|
||||
launchTemplateName: template?.LaunchTemplateName ?? template?.LaunchTemplateId ?? group.LaunchConfigurationName ?? null,
|
||||
launchTemplateVersion: template?.Version ?? null,
|
||||
subnets: (group.VPCZoneIdentifier ?? '').split(',').filter(Boolean),
|
||||
targetGroupArns: group.TargetGroupARNs ?? [],
|
||||
healthCheckType: group.HealthCheckType ?? null,
|
||||
suspendedProcesses: (group.SuspendedProcesses ?? []).map((process) => process.ProcessName).filter(Boolean),
|
||||
instances: instances.map((instance) => ({
|
||||
id: instance.InstanceId ?? '',
|
||||
availabilityZone: instance.AvailabilityZone ?? null,
|
||||
lifecycleState: instance.LifecycleState ?? null,
|
||||
health: instance.HealthStatus ?? null,
|
||||
instanceType: instance.InstanceType ?? null,
|
||||
protectedFromScaleIn: instance.ProtectedFromScaleIn ?? false,
|
||||
})),
|
||||
...(activities === 'unavailable' ? {activitiesUnavailable: true} : activities ? {
|
||||
activities: activities.map((activity) => ({
|
||||
id: activity.ActivityId ?? '',
|
||||
description: activity.Description ?? '',
|
||||
cause: activity.Cause ?? '',
|
||||
status: activity.StatusCode ?? null,
|
||||
statusMessage: activity.StatusMessage ?? null,
|
||||
startTime: activity.StartTime ? activity.StartTime.toISOString() : null,
|
||||
})),
|
||||
} : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function checkOrder({minSize, desiredCapacity, maxSize}: Required<AutoScalingCapacityInput>): void {
|
||||
if (minSize > maxSize) throw new ValidationError('minSize cannot be larger than maxSize')
|
||||
if (desiredCapacity < minSize || desiredCapacity > maxSize) throw new ValidationError('desiredCapacity must be between minSize and maxSize')
|
||||
}
|
||||
|
||||
function count(value: unknown, field: string): number {
|
||||
const raw = typeof value === 'number' ? value : Number(text(value))
|
||||
if (!Number.isInteger(raw) || raw < 0 || raw > AUTO_SCALING_MAX_INSTANCES || (typeof value === 'string' && !text(value))) {
|
||||
throw new ValidationError(`${field} must be a whole number from 0 to ${AUTO_SCALING_MAX_INSTANCES}`)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { LightsailClient } from "@aws-sdk/client-lightsail";
|
||||
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
|
||||
import { Route53Client } from "@aws-sdk/client-route-53";
|
||||
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
|
||||
import { AutoScalingClient } from "@aws-sdk/client-auto-scaling";
|
||||
|
||||
const endpoint = process.env.FLOCI_ENDPOINT;
|
||||
const region = process.env.AWS_REGION || "us-east-1";
|
||||
@@ -99,6 +100,7 @@ export type AwsClients = {
|
||||
cognito: CognitoIdentityProviderClient;
|
||||
route53: Route53Client;
|
||||
cloudfront: CloudFrontClient;
|
||||
autoscaling: AutoScalingClient;
|
||||
};
|
||||
|
||||
export type AwsClientName = keyof AwsClients;
|
||||
@@ -148,6 +150,7 @@ function buildClients(accountId: string): AwsClients {
|
||||
cognito: new CognitoIdentityProviderClient(base),
|
||||
route53: new Route53Client(base),
|
||||
cloudfront: new CloudFrontClient(base),
|
||||
autoscaling: new AutoScalingClient(base),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
/** CreateAutoScalingGroup's name rule: 1 to 255 characters, no colon. */
|
||||
export const AUTO_SCALING_GROUP_NAME_PATTERN = '^[^:]{1,255}$'
|
||||
/** A capacity the console accepts; AWS's own ceiling is far above what a laptop can run. */
|
||||
export const AUTO_SCALING_MAX_INSTANCES = 20
|
||||
|
||||
const groupColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Name'},
|
||||
{name: 'capacity', label: 'Instances', path: 'metadata.capacity'},
|
||||
{name: 'desired', label: 'Desired', path: 'metadata.desiredCapacity'},
|
||||
{name: 'min', label: 'Min', path: 'metadata.minSize'},
|
||||
{name: 'max', label: 'Max', path: 'metadata.maxSize'},
|
||||
{name: 'launchTemplate', label: 'Launch template', path: 'metadata.launchTemplateName', emptyText: '—'},
|
||||
{name: 'createdAt', label: 'Created', format: 'datetime'},
|
||||
]
|
||||
|
||||
const groupFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
const capacity = {pattern: '^\\d{1,2}$', message: `Use a whole number from 0 to ${AUTO_SCALING_MAX_INSTANCES}`}
|
||||
|
||||
export function awsAutoScalingSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'autoscaling',
|
||||
displayName: 'EC2 Auto Scaling groups',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
validation: {pattern: AUTO_SCALING_GROUP_NAME_PATTERN, message: 'Use up to 255 characters, without a colon'},
|
||||
},
|
||||
{
|
||||
name: 'launchTemplate',
|
||||
label: 'Launch template',
|
||||
type: 'text',
|
||||
required: true,
|
||||
description: 'The name or ID (lt-…) of an EC2 launch template. New instances use its image and instance type.',
|
||||
},
|
||||
{
|
||||
name: 'launchTemplateVersion',
|
||||
label: 'Template version',
|
||||
type: 'text',
|
||||
required: false,
|
||||
defaultValue: '$Latest',
|
||||
description: 'A version number, $Latest or $Default.',
|
||||
validation: {pattern: '^(\\d+|\\$Latest|\\$Default)$', message: 'Use a version number, $Latest or $Default'},
|
||||
},
|
||||
{name: 'minSize', label: 'Minimum', type: 'text', required: true, defaultValue: '1', validation: capacity},
|
||||
{name: 'desiredCapacity', label: 'Desired', type: 'text', required: false, defaultValue: '1', validation: capacity, description: 'How many instances to run now. Between the minimum and the maximum.'},
|
||||
{name: 'maxSize', label: 'Maximum', type: 'text', required: true, defaultValue: '2', validation: capacity},
|
||||
{
|
||||
name: 'subnets',
|
||||
label: 'Subnets',
|
||||
type: 'text',
|
||||
required: false,
|
||||
span: true,
|
||||
description: 'Subnet IDs to launch into, separated by commas. Leave empty for the default subnets.',
|
||||
validation: {pattern: '^\\s*subnet-[\\w-]+(\\s*,\\s*subnet-[\\w-]+)*\\s*$', message: 'Use subnet IDs separated by commas'},
|
||||
},
|
||||
{
|
||||
name: 'targetGroupArns',
|
||||
label: 'Target groups',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
span: true,
|
||||
description: 'Optional load balancer target group ARNs, one per line. Instances are registered with them as they launch.',
|
||||
},
|
||||
{
|
||||
name: 'healthCheckType',
|
||||
label: 'Health check',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'EC2',
|
||||
options: [
|
||||
{label: 'EC2 instance status', value: 'EC2'},
|
||||
{label: 'Load balancer health checks', value: 'ELB'},
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List Auto Scaling groups', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create Auto Scaling group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete group and its instances', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect Auto Scaling group', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
autoScalingActions: [
|
||||
{name: 'setCapacity', label: 'Change minimum, desired and maximum', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'terminateInstance', label: 'Terminate an instance', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: groupFilters,
|
||||
columns: groupColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function autoScalingSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsAutoScalingSchema() : null
|
||||
}
|
||||
@@ -74,6 +74,13 @@ export const SERVICE_CATALOG = {
|
||||
order: 20,
|
||||
},
|
||||
serverless: {displayName: 'Serverless', iconKey: 'serverless', group: 'Compute', order: 30},
|
||||
autoscaling: {
|
||||
displayName: 'Auto Scaling',
|
||||
displayNameByCloud: {aws: 'EC2 Auto Scaling'},
|
||||
iconKey: 'autoscaling',
|
||||
group: 'Compute',
|
||||
order: 15,
|
||||
},
|
||||
containers: {
|
||||
displayName: 'Containers',
|
||||
displayNameByCloud: {gcp: 'Cloud Run'},
|
||||
|
||||
@@ -44,6 +44,11 @@ export interface CloudServiceDescriptor {
|
||||
iconKey: string
|
||||
group: ServiceGroup
|
||||
order: number
|
||||
/**
|
||||
* Whether the generic resource list serves it. False for a service still on its
|
||||
* own pre-SPI page (MSK, MSK Connect): available, but not countable.
|
||||
*/
|
||||
listable: boolean
|
||||
}
|
||||
|
||||
export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
|
||||
@@ -181,6 +186,7 @@ export type UserPoolActionName =
|
||||
| 'describeClient'
|
||||
| 'updateClientOAuth'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||
export type HttpApiActionName =
|
||||
| 'describe'
|
||||
@@ -240,6 +246,7 @@ export interface ServiceSchema {
|
||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||
autoScalingActions?: CapabilitySchema<AutoScalingActionName>[]
|
||||
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
|
||||
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
|
||||
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
|
||||
@@ -510,6 +517,13 @@ export interface AlarmHistoryItem {
|
||||
summary: string
|
||||
}
|
||||
|
||||
/** Any of the three may be left out to keep its current value. */
|
||||
export interface AutoScalingCapacityInput {
|
||||
minSize?: number
|
||||
desiredCapacity?: number
|
||||
maxSize?: number
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
@@ -1177,6 +1191,9 @@ export interface CloudServiceAdapter {
|
||||
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
|
||||
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
|
||||
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
|
||||
setAutoScalingCapacity?(groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource>
|
||||
/** With `replace`, the group launches a new instance in its place; otherwise desired capacity drops by one. */
|
||||
terminateAutoScalingInstance?(groupName: string, instanceId: string, replace: boolean): Promise<void>
|
||||
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
|
||||
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
||||
clearEmailInbox?(): Promise<void>
|
||||
|
||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
import type {AutoScalingActionName, CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Guards the schema-to-implementation contract for every registered adapter.
|
||||
@@ -75,6 +75,11 @@ const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof Cloud
|
||||
createInvalidation: 'createDistributionInvalidation',
|
||||
}
|
||||
|
||||
const METHOD_FOR_AUTO_SCALING_ACTION: Record<AutoScalingActionName, keyof CloudServiceAdapter> = {
|
||||
setCapacity: 'setAutoScalingCapacity',
|
||||
terminateInstance: 'terminateAutoScalingInstance',
|
||||
}
|
||||
|
||||
const METHOD_FOR_USER_POOL_ACTION: Record<UserPoolActionName, keyof CloudServiceAdapter> = {
|
||||
updateUser: 'updateUserPoolUser',
|
||||
setUserGroups: 'setUserPoolUserGroups',
|
||||
@@ -188,6 +193,15 @@ describe('registered adapters honour their schema', () => {
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const autoScalingClaims = (adapter.schema().capabilities?.autoScalingActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of autoScalingClaims) {
|
||||
const method = METHOD_FOR_AUTO_SCALING_ACTION[capability.name]
|
||||
expect(
|
||||
typeof adapter[method],
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of distributionClaims) {
|
||||
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
|
||||
@@ -210,6 +224,7 @@ describe('registered adapters honour their schema', () => {
|
||||
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||
...(adapter.schema().capabilities?.distributionActions ?? []),
|
||||
...(adapter.schema().capabilities?.autoScalingActions ?? []),
|
||||
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
||||
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||
|
||||
@@ -50,6 +50,7 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
|
||||
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
|
||||
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
|
||||
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
|
||||
import {AwsAutoScalingAdapter} from './adapter-aws/AwsAutoScalingAdapter'
|
||||
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
@@ -103,6 +104,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsUserPoolsAdapter(clients.cognito),
|
||||
new AwsDnsAdapter(clients.route53),
|
||||
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
||||
new AwsAutoScalingAdapter(clients.autoscaling, clients.ec2),
|
||||
new AwsMonitoringAdapter(clients.cloudwatch),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s
|
||||
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
|
||||
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
|
||||
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
|
||||
import {awsAutoScalingSchema} from '../cloud-spi/autoScalingSchema'
|
||||
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||
@@ -823,6 +824,28 @@ describe('cloud schema routes', () => {
|
||||
expect(calls).toEqual([['enabled', 'E123', false], ['invalidate', 'E123', ['/*']]])
|
||||
})
|
||||
|
||||
test('changes capacity and terminates an instance through the Auto Scaling adapter', async () => {
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'autoscaling',
|
||||
schema: awsAutoScalingSchema,
|
||||
setAutoScalingCapacity: async (id, input) => {
|
||||
calls.push(['capacity', id, input])
|
||||
return {id, name: id, cloud: 'aws', service: 'autoscaling', type: 'autoScalingGroup', region: null, createdAt: null, metadata: {desiredCapacity: 3}}
|
||||
},
|
||||
terminateAutoScalingInstance: async (id, instanceId, replace) => {
|
||||
calls.push(['terminate', id, instanceId, replace])
|
||||
},
|
||||
})])
|
||||
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||
const base = '/api/clouds/aws/services/autoscaling/resources/web-asg'
|
||||
|
||||
const updated = await app.request(`${base}/capacity`, json('PUT', {desiredCapacity: 3}))
|
||||
expect((await updated.json()).metadata.desiredCapacity).toBe(3)
|
||||
expect((await app.request(`${base}/instances/i-abc/terminate`, json('POST', {replace: true}))).status).toBe(200)
|
||||
expect(calls).toEqual([['capacity', 'web-asg', {desiredCapacity: 3}], ['terminate', 'web-asg', 'i-abc', true]])
|
||||
})
|
||||
|
||||
test('clears the SES mailbox through the email adapter', async () => {
|
||||
let cleared = false
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
@@ -1403,6 +1426,18 @@ describe('service descriptors', () => {
|
||||
expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'})
|
||||
expect(secretsFor(gcp)).toMatchObject({availability: 'available', route: 'secrets'})
|
||||
})
|
||||
|
||||
test('marks a service listable only when an adapter serves the generic list', async () => {
|
||||
// MSK is available through its own pre-SPI page; Console Home must not count it
|
||||
// through /resources, which has no adapter behind it and answers 501.
|
||||
const body = await (await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/aws/services')).json() as Array<{service: string; availability: string; listable: boolean}>
|
||||
const of = (service: string) => body.find((descriptor) => descriptor.service === service)
|
||||
|
||||
expect(of('storage')).toMatchObject({availability: 'available', listable: true})
|
||||
expect(of('msk')).toMatchObject({availability: 'available', listable: false})
|
||||
expect(of('msk-connect')).toMatchObject({availability: 'available', listable: false})
|
||||
expect(of('serverless')).toMatchObject({availability: 'coming_soon', listable: false})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppConfig nested routes', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {Hono} from 'hono'
|
||||
import type {Context} from 'hono'
|
||||
import type {
|
||||
AlarmStateValue,
|
||||
AutoScalingCapacityInput,
|
||||
CloudProvider,
|
||||
CloudServiceType,
|
||||
CreateDatabaseSnapshotInput,
|
||||
@@ -525,6 +526,24 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/autoscaling/resources/:id/capacity', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => c.json(await svc(c).setAutoScalingCapacity(cloud, c.req.param('id'), await c.req.json<AutoScalingCapacityInput>())))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/autoscaling/resources/:id/instances/:instanceId/terminate', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
return withRuntime(c, async () => {
|
||||
const body = await c.req.json<{replace?: boolean}>()
|
||||
await svc(c).terminateAutoScalingInstance(cloud, c.req.param('id'), c.req.param('instanceId'), body.replace === true)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/k8s/resources/:id/nodegroups', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
@@ -93,6 +93,7 @@ import type {
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AutoScalingCapacityInput,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
@@ -177,6 +178,7 @@ export class CloudProxyService {
|
||||
iconKey: entry.iconKey,
|
||||
group: entry.group,
|
||||
order: entry.order,
|
||||
listable: Boolean(adapter),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -941,6 +943,18 @@ export class CloudProxyService {
|
||||
return adapter.createDistributionInvalidation(distributionId, paths)
|
||||
}
|
||||
|
||||
async setAutoScalingCapacity(cloud: CloudProvider, groupName: string, input: AutoScalingCapacityInput): Promise<CloudResource> {
|
||||
const adapter = this.requireAdapter(cloud, 'autoscaling')
|
||||
if (!adapter.setAutoScalingCapacity) throw new NotSupportedError(`Changing capacity is not supported for ${cloud}/autoscaling`)
|
||||
return adapter.setAutoScalingCapacity(groupName, input)
|
||||
}
|
||||
|
||||
async terminateAutoScalingInstance(cloud: CloudProvider, groupName: string, instanceId: string, replace: boolean): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'autoscaling')
|
||||
if (!adapter.terminateAutoScalingInstance) throw new NotSupportedError(`Terminating instances is not supported for ${cloud}/autoscaling`)
|
||||
return adapter.terminateAutoScalingInstance(groupName, instanceId, replace)
|
||||
}
|
||||
|
||||
async describeUserPoolClient(cloud: CloudProvider, poolId: string, clientId: string): Promise<UserPoolClientSettings> {
|
||||
const adapter = this.requireAdapter(cloud, 'userpools')
|
||||
if (!adapter.describeUserPoolClient) throw new NotSupportedError(`App client settings are not supported for ${cloud}/userpools`)
|
||||
|
||||
@@ -6,7 +6,7 @@ const STARTED_AT = new Date('2026-10-23T08:00:00.000Z')
|
||||
const NOW = new Date('2026-10-23T10:30:15.000Z')
|
||||
|
||||
function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor {
|
||||
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1}
|
||||
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1, listable: availability === 'available'}
|
||||
}
|
||||
|
||||
function proxy(status: Partial<CloudStatus> = {}, descriptors: CloudServiceDescriptor[] = []) {
|
||||
|
||||
@@ -150,6 +150,10 @@ export const apiEndpointKeys = {
|
||||
state: "clouds.services.monitoring.state",
|
||||
actions: "clouds.services.monitoring.actions",
|
||||
},
|
||||
autoscaling: {
|
||||
capacity: "clouds.services.autoscaling.capacity",
|
||||
terminate: "clouds.services.autoscaling.instances.terminate",
|
||||
},
|
||||
cdn: {
|
||||
enabled: "clouds.services.cdn.enabled",
|
||||
invalidations: "clouds.services.cdn.invalidations",
|
||||
@@ -926,6 +930,20 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}],
|
||||
[apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}],
|
||||
[
|
||||
apiEndpointKeys.clouds.autoscaling.capacity,
|
||||
{
|
||||
path: "/clouds/:cloud/services/autoscaling/resources/:id/capacity",
|
||||
method: "PUT",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.autoscaling.terminate,
|
||||
{
|
||||
path: "/clouds/:cloud/services/autoscaling/resources/:id/instances/:instanceId/terminate",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.cdn.enabled,
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AutoScalingCapacityInput,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
@@ -1679,6 +1680,32 @@ export async function testWebSocketApi(cloud: CloudProvider, apiId: string, inpu
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function setAutoScalingCapacity(
|
||||
cloud: CloudProvider,
|
||||
groupName: string,
|
||||
input: AutoScalingCapacityInput,
|
||||
): Promise<CloudResource> {
|
||||
const res = await apiClient.call<CloudResource, AutoScalingCapacityInput>(
|
||||
apiEndpointKeys.clouds.autoscaling.capacity,
|
||||
requestOptions(cloud, "autoscaling", { body: input }),
|
||||
{ cloud, id: groupName },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function terminateAutoScalingInstance(
|
||||
cloud: CloudProvider,
|
||||
groupName: string,
|
||||
instanceId: string,
|
||||
replace: boolean,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, { replace: boolean }>(
|
||||
apiEndpointKeys.clouds.autoscaling.terminate,
|
||||
requestOptions(cloud, "autoscaling", { body: { replace } }),
|
||||
{ cloud, id: groupName, instanceId },
|
||||
);
|
||||
}
|
||||
|
||||
export async function setDistributionEnabled(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {AutoScalingPanel} from './AutoScalingPanel'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCloudResource: vi.fn(),
|
||||
setAutoScalingCapacity: vi.fn(),
|
||||
terminateAutoScalingInstance: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
function group(metadata: Record<string, unknown> = {}): CloudResource {
|
||||
return {
|
||||
id: 'web-asg', name: 'web-asg', cloud: 'aws', service: 'autoscaling', type: 'autoScalingGroup', region: 'us-east-1', createdAt: null, status: null,
|
||||
metadata: {
|
||||
minSize: 1, desiredCapacity: 2, maxSize: 3, capacity: '2 in service',
|
||||
instances: [
|
||||
{id: 'i-b8736c8b4a8c129a3', availabilityZone: 'us-east-1a', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro'},
|
||||
{id: 'i-a3155c876867976b1', availabilityZone: 'us-east-1b', lifecycleState: 'InService', health: 'Healthy', instanceType: 't3.micro'},
|
||||
],
|
||||
activities: [{id: 'a1', description: 'Launching a new EC2 instance: i-b8736c8b4a8c129a3', cause: 'An instance was started in response to a desired capacity change.', status: 'Successful', statusMessage: null, startTime: null}],
|
||||
...metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel(resource: CloudResource) {
|
||||
api.getCloudResource.mockResolvedValue(resource)
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><AutoScalingPanel resource={resource} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.setAutoScalingCapacity.mockImplementation(async (_cloud, _id, input) => group({desiredCapacity: input.desiredCapacity}))
|
||||
api.terminateAutoScalingInstance.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
test('shows the instances and what launched them', async () => {
|
||||
renderPanel(group())
|
||||
|
||||
expect(await screen.findByText('i-a3155c876867976b1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Launching a new EC2 instance: i-b8736c8b4a8c129a3')).toBeInTheDocument()
|
||||
expect(screen.getByText(/never runs them/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('changes the desired capacity', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel(group())
|
||||
|
||||
const form = await screen.findByRole('form', {name: 'Group capacity'})
|
||||
const update = within(form).getByRole('button', {name: 'Update capacity'})
|
||||
expect(update).toBeDisabled()
|
||||
const desired = within(form).getByLabelText('Desired')
|
||||
await user.clear(desired)
|
||||
await user.type(desired, '3')
|
||||
await user.click(update)
|
||||
|
||||
expect(api.setAutoScalingCapacity).toHaveBeenCalledWith('aws', 'web-asg', {minSize: 1, desiredCapacity: 3, maxSize: 3})
|
||||
expect(await screen.findByText(/Capacity set to 3/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('replaces an instance, or removes one while above the minimum', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
renderPanel(group())
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Replace i-b8736c8b4a8c129a3'}))
|
||||
expect(api.terminateAutoScalingInstance).toHaveBeenCalledWith('aws', 'web-asg', 'i-b8736c8b4a8c129a3', true)
|
||||
expect(await screen.findByText(/launches a replacement/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Remove i-a3155c876867976b1'}))
|
||||
expect(api.terminateAutoScalingInstance).toHaveBeenLastCalledWith('aws', 'web-asg', 'i-a3155c876867976b1', false)
|
||||
})
|
||||
|
||||
test('cannot remove an instance from a group at its minimum', async () => {
|
||||
renderPanel(group({minSize: 2}))
|
||||
expect(await screen.findByRole('button', {name: 'Remove i-b8736c8b4a8c129a3'})).toBeDisabled()
|
||||
expect(screen.getByRole('button', {name: 'Replace i-b8736c8b4a8c129a3'})).toBeEnabled()
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import {RefreshCw} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {getCloudResource, setAutoScalingCapacity, terminateAutoScalingInstance} from '@/api/cloudProxyClient'
|
||||
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
type Instance = {id: string; availabilityZone: string | null; lifecycleState: string | null; health: string | null; instanceType: string | null}
|
||||
type Activity = {id: string; description: string; cause: string; status: string | null; statusMessage: string | null; startTime: string | null}
|
||||
type Capacity = {minSize: string; desiredCapacity: string; maxSize: string}
|
||||
|
||||
/** The runtime reconciles every 10 seconds, so a group that is still moving is re-read this often. */
|
||||
const SETTLING_POLL_MS = 4000
|
||||
|
||||
export function AutoScalingPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [capacity, setCapacity] = useState<Capacity>({minSize: '', desiredCapacity: '', maxSize: ''})
|
||||
const [notice, setNotice] = useState('')
|
||||
const groupKey = ['autoscaling-group', accountId, resource?.id]
|
||||
|
||||
const groupQuery = useQuery({
|
||||
queryKey: groupKey,
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'autoscaling', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
refetchInterval: (query) => query.state.data && !settled(query.state.data) ? SETTLING_POLL_MS : false,
|
||||
})
|
||||
|
||||
const group = groupQuery.data ?? resource
|
||||
useEffect(() => {
|
||||
setNotice('')
|
||||
}, [resource?.id])
|
||||
// The form follows the group's saved capacity whenever it changes, here or elsewhere.
|
||||
const savedMin = String(group?.metadata.minSize ?? '')
|
||||
const savedDesired = String(group?.metadata.desiredCapacity ?? '')
|
||||
const savedMax = String(group?.metadata.maxSize ?? '')
|
||||
useEffect(() => {
|
||||
setCapacity({minSize: savedMin, desiredCapacity: savedDesired, maxSize: savedMax})
|
||||
}, [group?.id, savedMin, savedDesired, savedMax])
|
||||
|
||||
const refresh = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: groupKey}),
|
||||
queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'autoscaling')}),
|
||||
])
|
||||
|
||||
const capacityMutation = useMutation({
|
||||
mutationFn: () => setAutoScalingCapacity('aws', resource?.id ?? '', {
|
||||
minSize: Number(capacity.minSize),
|
||||
desiredCapacity: Number(capacity.desiredCapacity),
|
||||
maxSize: Number(capacity.maxSize),
|
||||
}),
|
||||
onSuccess: (updated) => {
|
||||
setNotice(`Capacity set to ${String(updated.metadata.desiredCapacity)}. TheCloud launches or terminates instances within about 10 seconds.`)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const terminateMutation = useMutation({
|
||||
mutationFn: ({instanceId, replace}: {instanceId: string; replace: boolean}) => terminateAutoScalingInstance('aws', resource?.id ?? '', instanceId, replace),
|
||||
onSuccess: (_result, {instanceId, replace}) => {
|
||||
setNotice(replace ? `Terminating ${instanceId}. The group launches a replacement to stay at its desired capacity.` : `Terminating ${instanceId} and lowering the desired capacity by one.`)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource || !group) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select an Auto Scaling group</h3><p>Choose a group to change its capacity, see its instances and follow its scaling activity.</p></div></section>
|
||||
}
|
||||
|
||||
const metadata = group.metadata
|
||||
const instances = Array.isArray(metadata.instances) ? metadata.instances as Instance[] : []
|
||||
const activities = Array.isArray(metadata.activities) ? metadata.activities as Activity[] : []
|
||||
const desired = Number(metadata.desiredCapacity ?? 0)
|
||||
const min = Number(metadata.minSize ?? 0)
|
||||
const pending = capacityMutation.isPending || terminateMutation.isPending
|
||||
const disabled = !runtimeReachable || pending
|
||||
const error = groupQuery.error ?? capacityMutation.error ?? terminateMutation.error
|
||||
const changed = capacity.minSize !== String(metadata.minSize) || capacity.desiredCapacity !== String(metadata.desiredCapacity) || capacity.maxSize !== String(metadata.maxSize)
|
||||
const field = (name: keyof Capacity, label: string) => <label className="dynamic-field"><span>{label}</span>
|
||||
<input className="input" type="number" min={0} max={20} step={1} value={capacity[name]} onChange={(event) => setCapacity({...capacity, [name]: event.target.value})} disabled={disabled} required/>
|
||||
</label>
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Auto Scaling group ${group.name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Auto Scaling group</p><span className="muted">{String(metadata.capacity ?? '')} · desired {desired}{settled(group) ? '' : ' · scaling'}{group.status ? ` · ${group.status}` : ''}</span></div>
|
||||
<div className="cdn-actions">
|
||||
<button className="button" type="button" disabled={!runtimeReachable || groupQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{groupQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="dynamic-form autoscaling-capacity" aria-label="Group capacity" onSubmit={(event) => { event.preventDefault(); setNotice(''); capacityMutation.mutate() }}>
|
||||
{field('minSize', 'Minimum')}
|
||||
{field('desiredCapacity', 'Desired')}
|
||||
{field('maxSize', 'Maximum')}
|
||||
<div><button className="button primary" type="submit" disabled={disabled || !changed}>{capacityMutation.isPending ? 'Updating' : 'Update capacity'}</button></div>
|
||||
</form>
|
||||
<p className="muted compact-text autoscaling-note">Scaling policies are not offered: TheCloud stores EC2 scaling policies but never runs them, so set the capacity here. Alarm-driven scaling works for ECS services through Application Auto Scaling.</p>
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Instances</p><span className="muted">Each is a real EC2 instance in TheCloud, launched from the group's template.</span></div></div>
|
||||
{instances.length === 0 ? <p className="muted compact-text autoscaling-note">No instances. {desired > 0 ? 'The group is launching them.' : 'Raise the desired capacity to launch some.'}</p>
|
||||
: <div className="cognito-table"><table className="table"><thead><tr><th>Instance</th><th>State</th><th>Health</th><th>Zone</th><th>Type</th><th aria-label="Actions"/></tr></thead><tbody>{instances.map((instance) => (
|
||||
<tr key={instance.id}>
|
||||
<td className="mono">{instance.id}</td>
|
||||
<td><code>{instance.lifecycleState ?? '—'}</code></td>
|
||||
<td>{instance.health ?? '—'}</td>
|
||||
<td>{instance.availabilityZone ?? '—'}</td>
|
||||
<td>{instance.instanceType ?? '—'}</td>
|
||||
<td className="autoscaling-instance-actions">
|
||||
<button className="button compact" type="button" disabled={disabled || instance.lifecycleState !== 'InService'} aria-label={`Replace ${instance.id}`} onClick={() => { setNotice(''); terminateMutation.mutate({instanceId: instance.id, replace: true}) }}>Replace</button>
|
||||
<button className="button compact danger" type="button" disabled={disabled || instance.lifecycleState !== 'InService' || desired - 1 < min} title={desired - 1 < min ? 'The group is at its minimum size' : undefined} aria-label={`Remove ${instance.id}`} onClick={() => {
|
||||
setNotice('')
|
||||
if (window.confirm(`Terminate ${instance.id} and lower the desired capacity to ${desired - 1}?`)) terminateMutation.mutate({instanceId: instance.id, replace: false})
|
||||
}}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
|
||||
{activities.length > 0 && <>
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Activity</p><span className="muted">The most recent launches and terminations.</span></div></div>
|
||||
<div className="cognito-table"><table className="table"><thead><tr><th>Started</th><th>Status</th><th>Activity</th></tr></thead><tbody>{activities.map((activity) => (
|
||||
<tr key={activity.id}>
|
||||
<td>{formatDateTime(activity.startTime) ?? '—'}</td>
|
||||
<td><code>{activity.status ?? '—'}</code></td>
|
||||
<td>{activity.description}<br/><small className="muted">{activity.statusMessage || activity.cause}</small></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>
|
||||
</>}
|
||||
{metadata.activitiesUnavailable === true && <p className="muted compact-text autoscaling-note">The scaling activity could not be read just now.</p>}
|
||||
|
||||
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
/** Settled once every desired instance is in service and none is still starting or stopping. */
|
||||
function settled(group: CloudResource): boolean {
|
||||
const instances = Array.isArray(group.metadata.instances) ? group.metadata.instances as Instance[] : []
|
||||
const inService = instances.filter((instance) => instance.lifecycleState === 'InService').length
|
||||
return inService === Number(group.metadata.desiredCapacity ?? 0) && instances.every((instance) => instance.lifecycleState === 'InService')
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
|
||||
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
||||
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||
import { AutoScalingPanel } from "@/components/AutoScalingPanel";
|
||||
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
@@ -987,6 +988,9 @@ export function DynamicResourceView({
|
||||
{service === "dns" && cloud === "aws" && (
|
||||
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "autoscaling" && cloud === "aws" && (
|
||||
<AutoScalingPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "cdn" && cloud === "aws" && (
|
||||
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
MessageSquare,
|
||||
Network,
|
||||
Radio,
|
||||
Scaling,
|
||||
ScrollText,
|
||||
Scale,
|
||||
Server,
|
||||
@@ -36,6 +37,7 @@ import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy
|
||||
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
|
||||
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
|
||||
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
|
||||
import autoScalingIcon from '../../../../aws-icons-svg/Compute/EC2-Auto-Scaling.svg'
|
||||
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||
@@ -95,6 +97,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
userpools: UsersRound,
|
||||
dns: Globe,
|
||||
cdn: Earth,
|
||||
autoscaling: Scaling,
|
||||
monitoring: Activity,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
@@ -142,6 +145,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
userpools: cognitoIcon,
|
||||
dns: route53Icon,
|
||||
cdn: cloudFrontIcon,
|
||||
autoscaling: autoScalingIcon,
|
||||
monitoring: cloudWatchIcon,
|
||||
workflows: stepFunctionsIcon,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {ReactNode} from 'react'
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {renderHook, waitFor} from '@testing-library/react'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {useCloudConsoleHomeData} from './useCloudConsoleHomeData'
|
||||
import type {CloudServiceDescriptor} from '@/types/cloud'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
listClouds: vi.fn(),
|
||||
listCloudServices: vi.fn(),
|
||||
getCloudStatus: vi.fn(),
|
||||
listCloudResources: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
|
||||
function descriptor(service: CloudServiceDescriptor['service'], route: string, listable: boolean): CloudServiceDescriptor {
|
||||
return {cloud: 'aws', service, displayName: service, availability: 'available', route, iconKey: service, group: 'Storage', order: 1, listable}
|
||||
}
|
||||
|
||||
function wrapper({children}: {children: ReactNode}) {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.listClouds.mockResolvedValue([])
|
||||
api.getCloudStatus.mockResolvedValue({cloud: 'aws', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: '2026-10-25T10:00:00Z', error: null})
|
||||
api.listCloudServices.mockResolvedValue([
|
||||
descriptor('storage', 'storage', true),
|
||||
descriptor('secrets', '/secretsmanager', true),
|
||||
descriptor('msk', 'msk', false),
|
||||
])
|
||||
api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => {
|
||||
if (service === 'msk') throw new Error('501 operation_not_supported')
|
||||
return service === 'storage' ? [{id: 'a'}, {id: 'b'}] : [{id: 'c'}]
|
||||
})
|
||||
})
|
||||
|
||||
test('counts only services the generic list serves, so a pre-SPI page cannot fail the total', async () => {
|
||||
const {result} = renderHook(() => useCloudConsoleHomeData('aws'), {wrapper})
|
||||
|
||||
await waitFor(() => expect(result.current.resourceCount).toBe(3))
|
||||
expect(api.listCloudResources.mock.calls.map((call) => call[1]).sort()).toEqual(['secrets', 'storage'])
|
||||
expect(result.current.resourceDetail).toBe('Normalized resources across available services')
|
||||
expect(result.current.activeServices).toBe(3)
|
||||
|
||||
const msk = result.current.serviceCards.find((card) => card.id === 'msk')
|
||||
expect(msk).toMatchObject({route: '/cloud-explorer/aws/msk', meta: 'open service', count: undefined})
|
||||
expect(result.current.serviceCards.find((card) => card.id === 'secrets')).toMatchObject({route: '/secretsmanager', count: 1})
|
||||
})
|
||||
@@ -32,12 +32,12 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) {
|
||||
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.
|
||||
// Only a service with a registered adapter has the generic list endpoint. Route
|
||||
// shape only decides where the card links: a legacy absolute-route page (AWS
|
||||
// Secrets Manager) is still counted through its adapter, while MSK, available
|
||||
// through its own pre-SPI page, is not listable and is never counted.
|
||||
const countable = useMemo(
|
||||
() => services.filter((service) => service.availability === 'available'),
|
||||
() => services.filter((service) => service.availability === 'available' && service.listable),
|
||||
[services],
|
||||
)
|
||||
|
||||
@@ -85,7 +85,7 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) {
|
||||
: undefined,
|
||||
meta:
|
||||
service.availability === 'available'
|
||||
? isLegacyPage
|
||||
? isLegacyPage || !service.listable
|
||||
? 'open service'
|
||||
: serviceMetaLabel(status, counts?.isLoading ?? false, 'resources')
|
||||
: 'coming soon',
|
||||
|
||||
@@ -6610,3 +6610,8 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
.metric-chart-table { margin-top: 8px; }
|
||||
.metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; }
|
||||
.metric-chart-table table { margin-top: 8px; }
|
||||
|
||||
/* EC2 Auto Scaling: capacity fields side by side, instance actions in one row. */
|
||||
.autoscaling-capacity { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); align-items: end; padding: 4px 16px 12px; }
|
||||
.autoscaling-note { padding: 0 16px 12px; margin: 0; }
|
||||
.autoscaling-instance-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
|
||||
|
||||
@@ -57,6 +57,11 @@ export interface CloudServiceDescriptor {
|
||||
iconKey: string
|
||||
group: ServiceGroup
|
||||
order: number
|
||||
/**
|
||||
* Whether the generic resource list serves it. False for a service still on its
|
||||
* own pre-SPI page (MSK, MSK Connect): available, but not countable.
|
||||
*/
|
||||
listable: boolean
|
||||
}
|
||||
|
||||
export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
|
||||
|
||||
@@ -337,6 +337,12 @@ export interface AlarmHistoryItem {
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface AutoScalingCapacityInput {
|
||||
minSize?: number
|
||||
desiredCapacity?: number
|
||||
maxSize?: number
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
|
||||
@@ -28,6 +28,7 @@ export type KubernetesActionName =
|
||||
| 'deleteFargateProfile'
|
||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type AutoScalingActionName = 'setCapacity' | 'terminateInstance'
|
||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
@@ -91,6 +92,7 @@ export interface ServiceSchema {
|
||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||
autoScalingActions?: Array<CapabilitySchema<AutoScalingActionName>>
|
||||
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
||||
}
|
||||
filters: FieldSchema[]
|
||||
|
||||
@@ -19,6 +19,9 @@ importers:
|
||||
'@aws-sdk/client-appconfig':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1130.0
|
||||
'@aws-sdk/client-auto-scaling':
|
||||
specifier: ^3.1146.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-cloudformation':
|
||||
specifier: ^3.1111.0
|
||||
version: 3.1111.0
|
||||
@@ -310,6 +313,10 @@ packages:
|
||||
resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-auto-scaling@3.1156.0':
|
||||
resolution: {integrity: sha512-iv4obhbLigmUcxWnUNd1CJ+jQ8QtCbPg9Ae0dSmv7D1hq/jw50eoMXW8UblNKnOl4FSwWeCtWs4aoUUQYTl7IQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-cloudformation@3.1111.0':
|
||||
resolution: {integrity: sha512-tZHW6n0vbA1fiJZtxCWVFWUssP47es/EAu7kH2FId6nY/svNoQVHYTqA7ALhLqB+W6ASzNTpnJeCq8evoXlKBQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -2930,6 +2937,17 @@ snapshots:
|
||||
'@smithy/types': 4.17.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-auto-scaling@3.1156.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-node': 3.972.88
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-cloudformation@3.1111.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.977.8
|
||||
|
||||