Compare commits

..

6 Commits

Author SHA1 Message Date
Zakaria ae044bbbaa feat(api): add EventBridge Pipes to the cloud explorer
Create pipes from SQS, Kinesis, DynamoDB streams or MSK to Lambda, SQS, SNS,
EventBridge buses or Step Functions, with an optional filter, enrichment and
input template; start and stop them, see the flow with links to each resource,
and send a test message to an SQS source. Sources and targets the runtime's
poller cannot deliver between are refused on create.
2026-10-25 12:49:15 -04:00
Zakaria d4726950da Merge pull request 'fix(frontend): stop MSK breaking the Console Home resource count' (#22) from fix/console-home-resource-count into main
CI / checks (push) Successful in 27m6s
Reviewed-on: #22
2026-10-25 14:29:25 +00:00
Zakaria eeadeef664 fix(frontend): stop MSK breaking the Console Home resource count
CI / checks (pull_request) Successful in 26m59s
Console Home counted every available service through the generic resource
list, but MSK and MSK Connect are available through their own pre-SPI pages
with no adapter behind /resources, so each answered 501 and the Resources tile
read "Resource load failed". Service descriptors now say whether the generic
list serves them (listable, true when an adapter is registered), and Console
Home counts only those; the MSK cards still link to their pages.
2026-10-25 10:09:35 -04:00
Zakaria 9f1ea32183 Merge pull request 'docs: rebrand the README as TheCloud' (#21) from chore/readme-thecloud into main
CI / checks (push) Has been cancelled
Reviewed-on: #21
2026-10-25 14:00:14 +00:00
Zakaria 811791603c docs: rebrand the README as TheCloud
CI / checks (pull_request) Successful in 27m5s
Replace the Floci logo, screenshots and architecture diagram with TheCloud's
logo, a current screenshot and a redrawn diagram; say TheCloud in prose while
keeping literal identifiers (env vars, image and compose service names); point
the runtime setup at FlociClone; drop the upstream release and Docker badges
and the upstream community section; keep the credit to Floci under License.
2026-10-25 09:57:22 -04:00
Zakaria 67660858eb Merge pull request 'feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page' (#20) from feat/apigateway-v2 into main
CI / checks (push) Has been cancelled
Reviewed-on: #20
2026-10-25 13:49:09 +00:00
29 changed files with 958 additions and 468 deletions
+32 -61
View File
@@ -1,26 +1,23 @@
<p align="center"> <p align="center">
<img src="docs/images/floci-black.svg#gh-light-mode-only" alt="Floci UI" width="460" /> <img src="docs/images/thecloud-logo.png" alt="TheCloud" width="160" />
<img src="docs/images/floci-white.svg#gh-dark-mode-only" alt="Floci UI" width="460" />
</p> </p>
<h1 align="center">TheCloud</h1>
<p align="center"> <p align="center">
<strong>Any Cloud. Locally.</strong><br /> <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>
<p align="center"> <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> <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT"></a>
</p> </p>
<p align="center"> <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> </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. 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 ## 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. 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.
@@ -84,6 +81,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No | | Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
| Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No | | Integration | SNS / Notifications | Yes (list, create, inspect, delete) | No | No |
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No | | Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
| Integration | EventBridge Pipes / Pipes | Yes (list, create, delete, inspect) | No | No |
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No | | Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) | | Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
| Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No | | Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No |
@@ -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 server-supplied reason. Adding one is a catalog row in
`packages/api/src/cloud-spi/serviceCatalog.ts` plus an adapter — no frontend change. `packages/api/src/cloud-spi/serviceCatalog.ts` plus an adapter — no frontend change.
<p align="center"> With Azure selected, the nav is grouped the same way, `k8s Engine` is labelled
<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" /> `AKS` for that provider, and every unavailable service carries a reason: Serverless
</p> reads `coming soon` because the Azure runtime answers 501 for Azure Functions,
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,
even though an adapter is registered. even though an adapter is registered.
## Current Capability Snapshot ## Current Capability Snapshot
@@ -159,7 +153,7 @@ All three clouds, through the unified shell.
Current gaps: Current gaps:
- EKS and AKS are read-only. On AKS this is a runtime limit rather than a choice: - 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`. with, so a created cluster never leaves `provisioningState: Failed`.
</details> </details>
@@ -169,7 +163,7 @@ Current gaps:
Relational and document database workflows across providers: 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. - AWS RDS Snapshots: account-scoped Snapshots tab listing DB snapshots and supporting snapshot creation.
- Azure Cosmos DB NoSQL: database, container, and document workflows. - Azure Cosmos DB NoSQL: database, container, and document workflows.
- AWS DynamoDB: table management, item browsing, and Add record. - 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: 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 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 the current local Floci runtime. - AWS RDS instance stop/start operations are not implemented in TheCloud's runtime yet.
</details> </details>
@@ -280,7 +274,7 @@ Current gaps:
AWS SES email capture through the unified Cloud Explorer. 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. - Filters by subject, sender, and recipient.
- Inspects sender, recipients, timestamp, and message type. - Inspects sender, recipients, timestamp, and message type.
- Displays HTML in a sandboxed preview, text bodies, and captured raw MIME data. - 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 \ --endpoint-url http://localhost:4566 \
--from sender@example.test \ --from sender@example.test \
--destination 'ToAddresses=recipient@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 `/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: 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: 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. 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 - GCP Cloud Functions invoke is not wired yet; the capability is advertised as
`coming_soon` instead of being silently missing. `coming_soon` instead of being silently missing.
@@ -381,7 +375,7 @@ Current gaps:
## Product Direction ## 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: The guiding rules are:
@@ -393,7 +387,7 @@ The guiding rules are:
## Architecture ## Architecture
![Floci Unified UI Multi-Cloud Architecture](docs/images/floci-unified-ui-architecture.png) ![TheCloud architecture: the console calls TheCloud's API, which dispatches through one proxy and an adapter registry to AWS, Azure and GCP adapters and their local runtimes](docs/images/thecloud-architecture.svg)
Short implementation notes live in [docs/implementation-notes.md](docs/implementation-notes.md). Short implementation notes live in [docs/implementation-notes.md](docs/implementation-notes.md).
@@ -435,9 +429,9 @@ Browser
Default compose stack: Default compose stack:
- `floci-ui` on `http://localhost:4500` - `floci-ui` (the console) on `http://localhost:4500`
- `floci-api` on `http://localhost:4501` - `floci-api` (TheCloud's API) on `http://localhost:4501`
- `floci` on `http://localhost:4566` - `floci` (TheCloud's AWS runtime) on `http://localhost:4566`
Start AWS-only: Start AWS-only:
@@ -467,7 +461,7 @@ Prerequisites:
- Node.js 20+ - Node.js 20+
- pnpm 9+ - pnpm 9+
- Bun - 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: 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`. 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 ```bash
docker run -d --name floci \ git clone https://gitea.zacksolutions.dev/Zakaria/FlociClone.git ../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
cd ../floci cd ../floci
./mvnw clean quarkus:dev docker compose up -d # or ./mvnw clean quarkus:dev with JDK 25
``` ```
Optional local runtimes: Optional local runtimes:
- Floci-AZ on `http://localhost:4577` - Azure runtime (`floci-az`) on `http://localhost:4577`
- Floci-GCP on `http://localhost:4588` - GCP runtime (`floci-gcp`) on `http://localhost:4588`
Start the UI stack: Start the UI stack:
@@ -614,18 +597,6 @@ When adding new UI surface:
- Keep placeholders explicit instead of inventing fake data. - Keep placeholders explicit instead of inventing fake data.
- Update this README when the visible UI surface changes. - 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 ## License
[MIT](LICENSE) — part of the [Floci](https://floci.io) ecosystem. [MIT](LICENSE). TheCloud builds on the open-source [Floci](https://floci.io) project.
-200
View File
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

-200
View File
@@ -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

+72
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

+1
View File
@@ -36,6 +36,7 @@
"@aws-sdk/client-kms": "^3.1096.0", "@aws-sdk/client-kms": "^3.1096.0",
"@aws-sdk/client-lambda": "^3.1076.0", "@aws-sdk/client-lambda": "^3.1076.0",
"@aws-sdk/client-lightsail": "^3.1076.0", "@aws-sdk/client-lightsail": "^3.1076.0",
"@aws-sdk/client-pipes": "^3.1146.0",
"@aws-sdk/client-rds": "^3.1076.0", "@aws-sdk/client-rds": "^3.1076.0",
"@aws-sdk/client-rds-data": "^3.1076.0", "@aws-sdk/client-rds-data": "^3.1076.0",
"@aws-sdk/client-route-53": "^3.1146.0", "@aws-sdk/client-route-53": "^3.1146.0",
@@ -0,0 +1,122 @@
import {describe, expect, test} from 'bun:test'
import {
CreatePipeCommand,
DeletePipeCommand,
DescribePipeCommand,
ListPipesCommand,
type PipesClient,
StartPipeCommand,
StopPipeCommand,
} from '@aws-sdk/client-pipes'
import {AwsPipesAdapter, describeArn} from './AwsPipesAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubPipes(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as PipesClient
return {client, sent, adapter: new AwsPipesAdapter(client)}
}
const SRC = 'arn:aws:sqs:us-east-1:000000000000:pipe-src'
const DST = 'arn:aws:lambda:us-east-1:000000000000:function:worker'
const ROLE = 'arn:aws:iam::000000000000:role/pipe-role'
const described = {
Name: 'orders-pipe', Arn: 'arn:aws:pipes:us-east-1:000000000000:pipe/orders-pipe',
DesiredState: 'RUNNING', CurrentState: 'RUNNING', Source: SRC, Target: DST, RoleArn: ROLE,
SourceParameters: {FilterCriteria: {Filters: [{Pattern: '{"body":{"kind":["order"]}}'}]}, SqsQueueParameters: {BatchSize: 5}},
TargetParameters: {InputTemplate: '{"id": <$.messageId>}'},
CreationTime: new Date('2026-10-25T16:42:45Z'),
}
describe('AwsPipesAdapter', () => {
test('lists pipes across pages with readable source and target names', async () => {
const {adapter} = stubPipes((command) => {
if (!(command instanceof ListPipesCommand)) return {}
return command.input.NextToken
? {Pipes: [{Name: 'clicks', CurrentState: 'STOPPED', Source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', Target: 'arn:aws:events:us-east-1:000000000000:event-bus/default'}]}
: {Pipes: [{Name: 'orders-pipe', CurrentState: 'RUNNING', Source: SRC, Target: DST}], NextToken: 'n'}
})
const pipes = await adapter.list()
expect(pipes.map((pipe) => [pipe.name, pipe.status, pipe.metadata.sourceName, pipe.metadata.targetName])).toEqual([
['orders-pipe', 'RUNNING', 'pipe-src (SQS queue)', 'worker (Lambda function)'],
['clicks', 'STOPPED', 'clicks (Kinesis stream)', 'default (EventBridge event bus)'],
])
expect((await adapter.list({search: 'kinesis'})).map((pipe) => pipe.name)).toEqual(['clicks'])
})
test('describes the filter, batch size, template and role', async () => {
const {adapter} = stubPipes(() => described)
expect((await adapter.get('orders-pipe'))?.metadata).toMatchObject({
filterPatterns: ['{"body":{"kind":["order"]}}'],
batchSize: 5,
inputTemplate: '{"id": <$.messageId>}',
roleArn: ROLE,
})
})
test('a missing pipe is null', async () => {
const {adapter} = stubPipes(() => { throw Object.assign(new Error('gone'), {name: 'NotFoundException'}) })
expect(await adapter.get('nope')).toBeNull()
})
test('creates an SQS pipe with its filter, batch size and template', async () => {
const {adapter, sent} = stubPipes((command) => command instanceof DescribePipeCommand ? described : {})
await adapter.create({values: {name: 'orders-pipe', source: SRC, target: DST, roleArn: ROLE, batchSize: '5', filterPattern: '{ "body": { "kind": ["order"] } }', inputTemplate: '{"id": <$.messageId>}'}})
expect((sent[0] as CreatePipeCommand).input).toEqual({
Name: 'orders-pipe',
Source: SRC,
SourceParameters: {FilterCriteria: {Filters: [{Pattern: '{"body":{"kind":["order"]}}'}]}, SqsQueueParameters: {BatchSize: 5}},
Target: DST,
TargetParameters: {InputTemplate: '{"id": <$.messageId>}'},
RoleArn: ROLE,
DesiredState: 'RUNNING',
})
})
test('gives stream and MSK sources the starting position they require', async () => {
const {adapter, sent} = stubPipes((command) => command instanceof DescribePipeCommand ? described : {})
await adapter.create({values: {name: 'clicks', source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', target: DST, roleArn: ROLE, startingPosition: 'TRIM_HORIZON', desiredState: 'STOPPED'}})
expect((sent[0] as CreatePipeCommand).input).toMatchObject({SourceParameters: {KinesisStreamParameters: {StartingPosition: 'TRIM_HORIZON'}}, DesiredState: 'STOPPED'})
await adapter.create({values: {name: 'k', source: 'arn:aws:kafka:us-east-1:000000000000:cluster/events/abc', target: DST, roleArn: ROLE, topicName: 'orders'}})
expect((sent[2] as CreatePipeCommand).input.SourceParameters).toEqual({ManagedStreamingKafkaParameters: {TopicName: 'orders', StartingPosition: 'LATEST'}})
})
test('refuses pipes the runtime could not run, before calling it', async () => {
const {adapter, sent} = stubPipes(() => ({}))
const base = {name: 'p', source: SRC, target: DST, roleArn: ROLE}
await expect(adapter.create({values: {...base, target: 'arn:aws:s3:::bucket'}})).rejects.toThrow('target must be the ARN of a')
await expect(adapter.create({values: {...base, source: 'arn:aws:sns:us-east-1:000000000000:topic'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {...base, enrichment: 'arn:aws:sqs:us-east-1:000000000000:q'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {...base, filterPattern: '{kind: order}'}})).rejects.toThrow('filterPattern must be JSON')
await expect(adapter.create({values: {...base, batchSize: '20000'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {...base, source: 'arn:aws:kafka:us-east-1:000000000000:cluster/c/1'}})).rejects.toThrow('topicName is required')
await expect(adapter.create({values: {...base, roleArn: 'pipe-role'}})).rejects.toBeInstanceOf(ValidationError)
expect(sent).toHaveLength(0)
})
test('starts, stops and deletes by name', async () => {
const {adapter, sent} = stubPipes(() => ({}))
await adapter.stop('orders-pipe')
await adapter.start('orders-pipe')
await adapter.delete('orders-pipe')
expect(sent.map((command) => command.constructor)).toEqual([StopPipeCommand, StartPipeCommand, DeletePipeCommand])
expect(sent.every((command) => (command as StopPipeCommand).input.Name === 'orders-pipe')).toBe(true)
})
test('names every kind of source and target ARN', () => {
expect(describeArn('arn:aws:dynamodb:us-east-1:000000000000:table/orders/stream/2026-10-25T00:00:00.000')).toBe('orders (DynamoDB stream)')
expect(describeArn('arn:aws:states:us-east-1:000000000000:stateMachine:flow')).toBe('flow (Step Functions state machine)')
expect(describeArn('arn:aws:sns:us-east-1:000000000000:alerts')).toBe('alerts (SNS topic)')
})
})
@@ -0,0 +1,237 @@
import {
CreatePipeCommand,
DeletePipeCommand,
DescribePipeCommand,
type DescribePipeCommandOutput,
ListPipesCommand,
type Pipe,
type PipeSourceParameters,
type PipesClient,
StartPipeCommand,
StopPipeCommand,
} from '@aws-sdk/client-pipes'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {
awsPipesSchema,
PIPE_ENRICHMENT_SERVICES,
PIPE_NAME_PATTERN,
PIPE_SOURCE_SERVICES,
PIPE_STARTING_POSITIONS,
PIPE_TARGET_SERVICES,
} from '../cloud-spi/pipesSchema'
import type {CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
const MAX_BATCH_SIZE = 10_000
/**
* Talks to EventBridge Pipes, verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - Pipes really run: the runtime polls SQS, Kinesis, DynamoDB streams and MSK,
* applies the filter, enrichment and input template, and calls the target.
* - Its poller delivers only to Lambda, SQS, SNS, EventBridge buses and Step
* Functions, so create refuses other targets instead of making a pipe that
* would never deliver.
* - ListPipes returns no parameters, so the table shows only what it carries;
* the filter, template and role come from DescribePipe.
*/
export class AwsPipesAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'pipes' as const
constructor(private readonly pipes: PipesClient) {}
schema(): ServiceSchema {
return awsPipesSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const found: Pipe[] = []
let next: string | undefined
do {
const res = await this.pipes.send(new ListPipesCommand({Limit: 100, ...(next ? {NextToken: next} : {})}))
found.push(...(res.Pipes ?? []))
next = res.NextToken
} while (next)
const search = query.search?.trim().toLowerCase()
return found.map((pipe) => toResource(pipe)).filter((pipe) => !search
|| pipe.name.toLowerCase().includes(search)
|| String(pipe.metadata.source).toLowerCase().includes(search)
|| String(pipe.metadata.target).toLowerCase().includes(search))
}
async get(id: string): Promise<CloudResource | null> {
try {
return toResource(await this.pipes.send(new DescribePipeCommand({Name: id})))
} catch (error) {
if (errorName(error) === 'NotFoundException') return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const name = text(values.name)
if (!new RegExp(PIPE_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 64 letters, numbers, dots, dashes or underscores')
const source = text(values.source)
const sourceService = supported(source, PIPE_SOURCE_SERVICES, 'source')
const target = text(values.target)
supported(target, PIPE_TARGET_SERVICES, 'target')
const enrichment = text(values.enrichment)
if (enrichment) supported(enrichment, PIPE_ENRICHMENT_SERVICES, 'enrichment')
const roleArn = text(values.roleArn)
if (!/^arn:aws[a-z-]*:iam::\d{12}:role\/.+$/.test(roleArn)) throw new ValidationError('roleArn must be an IAM role ARN')
const inputTemplate = text(values.inputTemplate)
const description = text(values.description)
const desiredState = values.desiredState === 'STOPPED' ? 'STOPPED' : 'RUNNING'
await this.pipes.send(new CreatePipeCommand({
Name: name,
Source: source,
SourceParameters: sourceParameters(sourceService, values),
Target: target,
...(inputTemplate ? {TargetParameters: {InputTemplate: inputTemplate}} : {}),
...(enrichment ? {Enrichment: enrichment} : {}),
RoleArn: roleArn,
DesiredState: desiredState,
...(description ? {Description: description} : {}),
}))
const created = await this.get(name)
if (!created) throw new RuntimeError(`EventBridge Pipes did not return the new pipe ${name}`)
return created
}
async delete(id: string): Promise<void> {
await this.pipes.send(new DeletePipeCommand({Name: id}))
}
async start(id: string): Promise<void> {
await this.pipes.send(new StartPipeCommand({Name: id}))
}
async stop(id: string): Promise<void> {
await this.pipes.send(new StopPipeCommand({Name: id}))
}
}
/** Each source kind carries its own parameter block, as CreatePipe expects. */
function sourceParameters(service: keyof typeof PIPE_SOURCE_SERVICES, values: Record<string, unknown>): PipeSourceParameters {
const batchSize = optionalBatchSize(values.batchSize)
const filterPattern = text(values.filterPattern)
const parameters: PipeSourceParameters = filterPattern ? {FilterCriteria: {Filters: [{Pattern: jsonObject(filterPattern, 'filterPattern')}]}} : {}
const batch = batchSize ? {BatchSize: batchSize} : {}
if (service === 'sqs') return {...parameters, SqsQueueParameters: batch}
const startingPosition = text(values.startingPosition) || 'LATEST'
if (!(PIPE_STARTING_POSITIONS as readonly string[]).includes(startingPosition)) {
throw new ValidationError(`startingPosition must be one of ${PIPE_STARTING_POSITIONS.join(', ')}`)
}
const position = startingPosition as (typeof PIPE_STARTING_POSITIONS)[number]
if (service === 'kinesis') return {...parameters, KinesisStreamParameters: {StartingPosition: position, ...batch}}
if (service === 'dynamodb') return {...parameters, DynamoDBStreamParameters: {StartingPosition: position, ...batch}}
const topicName = text(values.topicName)
if (!topicName) throw new ValidationError('topicName is required for an MSK source')
return {...parameters, ManagedStreamingKafkaParameters: {TopicName: topicName, StartingPosition: position, ...batch}}
}
function toResource(pipe: Pipe | DescribePipeCommandOutput): CloudResource {
const name = pipe.Name ?? ''
const detail = 'SourceParameters' in pipe ? pipe : undefined
const sourceParameters = detail?.SourceParameters
const batchSize = sourceParameters?.SqsQueueParameters?.BatchSize
?? sourceParameters?.KinesisStreamParameters?.BatchSize
?? sourceParameters?.DynamoDBStreamParameters?.BatchSize
?? sourceParameters?.ManagedStreamingKafkaParameters?.BatchSize
return {
id: name,
name,
cloud: 'aws',
service: 'pipes',
type: 'pipe',
region: pipe.Arn?.split(':')[3] || null,
createdAt: pipe.CreationTime ? pipe.CreationTime.toISOString() : null,
status: pipe.CurrentState ?? null,
metadata: {
arn: pipe.Arn,
desiredState: pipe.DesiredState ?? null,
currentState: pipe.CurrentState ?? null,
stateReason: pipe.StateReason ?? null,
source: pipe.Source ?? null,
sourceName: describeArn(pipe.Source),
target: pipe.Target ?? null,
targetName: describeArn(pipe.Target),
enrichment: pipe.Enrichment ?? null,
lastModified: pipe.LastModifiedTime ? pipe.LastModifiedTime.toISOString() : null,
...(detail ? {
description: detail.Description ?? null,
roleArn: detail.RoleArn ?? null,
filterPatterns: (sourceParameters?.FilterCriteria?.Filters ?? []).map((filter) => filter.Pattern ?? '').filter(Boolean),
batchSize: batchSize ?? null,
startingPosition: sourceParameters?.KinesisStreamParameters?.StartingPosition
?? sourceParameters?.DynamoDBStreamParameters?.StartingPosition
?? sourceParameters?.ManagedStreamingKafkaParameters?.StartingPosition
?? null,
topicName: sourceParameters?.ManagedStreamingKafkaParameters?.TopicName ?? null,
inputTemplate: detail.TargetParameters?.InputTemplate ?? null,
enrichmentInputTemplate: detail.EnrichmentParameters?.InputTemplate ?? null,
} : {}),
},
}
}
/**
* A readable name for an ARN: `queue (SQS queue)`. The resource part differs by
* service — `function:name`, `stream/name`, `table/name/stream/…`, `cluster/name/uuid`.
*/
export function describeArn(arn: string | undefined): string | null {
if (!arn) return null
const parts = arn.split(':')
const service = parts[2] ?? ''
const resource = parts.slice(5).join(':')
const name = service === 'lambda' || service === 'states' ? resource.split(':')[1] ?? resource
: service === 'dynamodb' || service === 'kafka' || service === 'kinesis' || service === 'events' ? resource.split('/')[1] ?? resource
: resource
const kind = (PIPE_SOURCE_SERVICES as Record<string, string>)[service] ?? (PIPE_TARGET_SERVICES as Record<string, string>)[service]
return kind ? `${name} (${kind})` : name
}
function supported<T extends Record<string, string>>(arn: string, services: T, field: string): keyof T {
const service = arn.split(':')[2] ?? ''
if (!arn.startsWith('arn:') || !(service in services)) {
throw new ValidationError(`${field} must be the ARN of a ${Object.values(services).join(', ')}`)
}
return service as keyof T
}
function optionalBatchSize(value: unknown): number | undefined {
const raw = text(value)
if (!raw) return undefined
const size = Number(raw)
if (!Number.isInteger(size) || size < 1 || size > MAX_BATCH_SIZE) throw new ValidationError(`batchSize must be a whole number from 1 to ${MAX_BATCH_SIZE}`)
return size
}
function jsonObject(value: string, field: string): string {
let parsed: unknown
try {
parsed = JSON.parse(value)
} catch {
throw new ValidationError(`${field} must be JSON, such as {"body": {"kind": ["order"]}}`)
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new ValidationError(`${field} must be a JSON object`)
return JSON.stringify(parsed)
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function errorName(error: unknown): string | undefined {
return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined
}
+3
View File
@@ -34,6 +34,7 @@ import { LightsailClient } from "@aws-sdk/client-lightsail";
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider"; import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
import { Route53Client } from "@aws-sdk/client-route-53"; import { Route53Client } from "@aws-sdk/client-route-53";
import { CloudFrontClient } from "@aws-sdk/client-cloudfront"; import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
import { PipesClient } from "@aws-sdk/client-pipes";
const endpoint = process.env.FLOCI_ENDPOINT; const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1"; const region = process.env.AWS_REGION || "us-east-1";
@@ -99,6 +100,7 @@ export type AwsClients = {
cognito: CognitoIdentityProviderClient; cognito: CognitoIdentityProviderClient;
route53: Route53Client; route53: Route53Client;
cloudfront: CloudFrontClient; cloudfront: CloudFrontClient;
pipes: PipesClient;
}; };
export type AwsClientName = keyof AwsClients; export type AwsClientName = keyof AwsClients;
@@ -148,6 +150,7 @@ function buildClients(accountId: string): AwsClients {
cognito: new CognitoIdentityProviderClient(base), cognito: new CognitoIdentityProviderClient(base),
route53: new Route53Client(base), route53: new Route53Client(base),
cloudfront: new CloudFrontClient(base), cloudfront: new CloudFrontClient(base),
pipes: new PipesClient(base),
}; };
} }
+151
View File
@@ -0,0 +1,151 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/** CreatePipe's name rule. */
export const PIPE_NAME_PATTERN = '^[\\.\\-_A-Za-z0-9]{1,64}$'
export const PIPE_STARTING_POSITIONS = ['TRIM_HORIZON', 'LATEST'] as const
/**
* The sources and targets the runtime's pipe poller actually moves events
* between, keyed by the ARN's service field. Anything else is refused on create
* rather than accepted into a pipe that would never deliver.
*/
export const PIPE_SOURCE_SERVICES = {sqs: 'SQS queue', kinesis: 'Kinesis stream', dynamodb: 'DynamoDB stream', kafka: 'MSK topic'} as const
export const PIPE_TARGET_SERVICES = {lambda: 'Lambda function', sqs: 'SQS queue', sns: 'SNS topic', events: 'EventBridge event bus', states: 'Step Functions state machine'} as const
export const PIPE_ENRICHMENT_SERVICES = {lambda: 'Lambda function', states: 'Step Functions state machine'} as const
const ARN = '^arn:aws[a-z-]*:[a-z0-9-]+:[a-z0-9-]*:\\d{12}:.+$'
const pipeColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'status', label: 'State', format: 'badge'},
{name: 'source', label: 'Source', path: 'metadata.sourceName'},
{name: 'target', label: 'Target', path: 'metadata.targetName'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const pipeFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsPipesSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'pipes',
displayName: 'EventBridge Pipes',
fields: [
{
name: 'name',
label: 'Name',
type: 'text',
required: true,
validation: {pattern: PIPE_NAME_PATTERN, message: 'Use up to 64 letters, numbers, dots, dashes and underscores'},
},
{name: 'description', label: 'Description', type: 'text', required: false, validation: {maxLength: 512}},
{
name: 'source',
label: 'Source ARN',
type: 'text',
required: true,
span: true,
description: 'Where events come from: an SQS queue, a Kinesis stream, a DynamoDB stream or an MSK cluster.',
validation: {pattern: ARN, message: 'Use the ARN of a queue or stream'},
},
{
name: 'startingPosition',
label: 'Starting position',
type: 'select',
required: false,
defaultValue: 'LATEST',
description: 'For streams and MSK: read new records only, or everything still in the stream.',
options: [
{label: 'New records only (LATEST)', value: 'LATEST'},
{label: 'Oldest record (TRIM_HORIZON)', value: 'TRIM_HORIZON'},
],
},
{
name: 'topicName',
label: 'MSK topic',
type: 'text',
required: false,
description: 'Required when the source is an MSK cluster.',
},
{
name: 'batchSize',
label: 'Batch size',
type: 'text',
required: false,
description: 'Records sent to the target at once. 1 to 10,000; leave empty for the default of 10.',
validation: {pattern: '^[1-9][0-9]{0,4}$', message: 'Use a whole number from 1 to 10000'},
},
{
name: 'filterPattern',
label: 'Filter pattern',
type: 'textarea',
required: false,
span: true,
description: 'Optional EventBridge event pattern; only matching records go on. For SQS, match the parsed message under body, for example {"body": {"kind": ["order"]}}.',
},
{
name: 'enrichment',
label: 'Enrichment ARN',
type: 'text',
required: false,
span: true,
description: 'Optional Lambda function or Step Functions state machine that transforms each batch before the target gets it.',
validation: {pattern: ARN, message: 'Use the ARN of a Lambda function or state machine'},
},
{
name: 'target',
label: 'Target ARN',
type: 'text',
required: true,
span: true,
description: 'Where events go: a Lambda function, SQS queue, SNS topic, EventBridge event bus or Step Functions state machine.',
validation: {pattern: ARN, message: 'Use the ARN of the target'},
},
{
name: 'inputTemplate',
label: 'Target input template',
type: 'textarea',
required: false,
span: true,
description: 'Optional. Reshape what the target receives, with <$.path> placeholders, for example {"id": <$.messageId>}.',
},
{
name: 'roleArn',
label: 'Execution role ARN',
type: 'text',
required: true,
span: true,
description: 'The IAM role the pipe uses to read the source and call the target, as AWS requires. Create one in IAM first.',
validation: {pattern: '^arn:aws[a-z-]*:iam::\\d{12}:role/.+$', message: 'Use an IAM role ARN such as arn:aws:iam::000000000000:role/pipe-role'},
},
{
name: 'desiredState',
label: 'State after creating',
type: 'select',
required: false,
defaultValue: 'RUNNING',
options: [
{label: 'Running', value: 'RUNNING'},
{label: 'Stopped', value: 'STOPPED'},
],
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List pipes', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create pipe', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete pipe', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect pipe', enabled: true, status: 'available', runtimeRequired: true},
{name: 'start', label: 'Start pipe', enabled: true, status: 'available', runtimeRequired: true},
{name: 'stop', label: 'Stop pipe', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: pipeFilters,
columns: pipeColumns,
}
}
export function pipesSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsPipesSchema() : null
}
@@ -274,6 +274,13 @@ export const SERVICE_CATALOG = {
group: 'Provisioning', group: 'Provisioning',
order: 20, order: 20,
}, },
pipes: {
displayName: 'Pipes',
displayNameByCloud: {aws: 'EventBridge Pipes'},
iconKey: 'pipes',
group: 'Integration',
order: 25,
},
scheduler: { scheduler: {
displayName: 'Cloud Scheduler', displayName: 'Cloud Scheduler',
displayNameByCloud: {gcp: 'Cloud Scheduler'}, displayNameByCloud: {gcp: 'Cloud Scheduler'},
+5
View File
@@ -44,6 +44,11 @@ export interface CloudServiceDescriptor {
iconKey: string iconKey: string
group: ServiceGroup group: ServiceGroup
order: number 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' export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
+2
View File
@@ -50,6 +50,7 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter' import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter' import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter' import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
import {AwsPipesAdapter} from './adapter-aws/AwsPipesAdapter'
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter' import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
@@ -103,6 +104,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsUserPoolsAdapter(clients.cognito), new AwsUserPoolsAdapter(clients.cognito),
new AwsDnsAdapter(clients.route53), new AwsDnsAdapter(clients.route53),
new AwsCdnAdapter(clients.cloudfront, clients.s3), new AwsCdnAdapter(clients.cloudfront, clients.s3),
new AwsPipesAdapter(clients.pipes),
new AwsMonitoringAdapter(clients.cloudwatch), new AwsMonitoringAdapter(clients.cloudwatch),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
+12
View File
@@ -1403,6 +1403,18 @@ describe('service descriptors', () => {
expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'}) expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'})
expect(secretsFor(gcp)).toMatchObject({availability: 'available', route: 'secrets'}) 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', () => { describe('AppConfig nested routes', () => {
@@ -177,6 +177,7 @@ export class CloudProxyService {
iconKey: entry.iconKey, iconKey: entry.iconKey,
group: entry.group, group: entry.group,
order: entry.order, order: entry.order,
listable: Boolean(adapter),
} }
}) })
} }
@@ -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') const NOW = new Date('2026-10-23T10:30:15.000Z')
function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor { 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[] = []) { function proxy(status: Partial<CloudStatus> = {}, descriptors: CloudServiceDescriptor[] = []) {
@@ -68,6 +68,7 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel"; import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel"; import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { CloudFrontPanel } from "@/components/CloudFrontPanel"; import { CloudFrontPanel } from "@/components/CloudFrontPanel";
import { PipePanel } from "@/components/PipePanel";
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel"; import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer"; import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger"; import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
@@ -987,6 +988,9 @@ export function DynamicResourceView({
{service === "dns" && cloud === "aws" && ( {service === "dns" && cloud === "aws" && (
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
{service === "pipes" && cloud === "aws" && (
<PipePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "cdn" && cloud === "aws" && ( {service === "cdn" && cloud === "aws" && (
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
@@ -0,0 +1,81 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {MemoryRouter} from 'react-router-dom'
import {beforeEach, expect, test, vi} from 'vitest'
import {PipePanel} from './PipePanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
startCloudResource: vi.fn(),
stopCloudResource: vi.fn(),
sendSqsMessage: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
function pipe(status = 'RUNNING', metadata: Record<string, unknown> = {}): CloudResource {
return {
id: 'orders-pipe', name: 'orders-pipe', cloud: 'aws', service: 'pipes', type: 'pipe', region: 'us-east-1', createdAt: null, status,
metadata: {
desiredState: status, source: 'arn:aws:sqs:us-east-1:000000000000:pipe-src', sourceName: 'pipe-src (SQS queue)',
target: 'arn:aws:lambda:us-east-1:000000000000:function:worker', targetName: 'worker (Lambda function)',
filterPatterns: ['{"body":{"kind":["order"]}}'], batchSize: 5, inputTemplate: '{"id": <$.messageId>}',
roleArn: 'arn:aws:iam::000000000000:role/pipe-role',
...metadata,
},
}
}
function renderPanel(resource: CloudResource) {
api.getCloudResource.mockResolvedValue(resource)
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<MemoryRouter><QueryClientProvider client={client}><PipePanel resource={resource} runtimeReachable/></QueryClientProvider></MemoryRouter>)
}
beforeEach(() => {
vi.clearAllMocks()
api.startCloudResource.mockResolvedValue(undefined)
api.stopCloudResource.mockResolvedValue(undefined)
api.sendSqsMessage.mockResolvedValue({messageId: 'm-1'})
})
test('shows the flow from source through the filter to the target, linked to each service', async () => {
renderPanel(pipe())
const flow = await screen.findByRole('list', {name: 'Event flow'})
expect(within(flow).getByRole('link', {name: 'pipe-src (SQS queue)'})).toHaveAttribute('href', '/cloud-explorer/aws/messaging')
expect(within(flow).getByRole('link', {name: 'worker (Lambda function)'})).toHaveAttribute('href', '/cloud-explorer/aws/serverless')
expect(within(flow).getByText('{"body":{"kind":["order"]}}')).toBeInTheDocument()
expect(within(flow).getByText('batches of 5')).toBeInTheDocument()
expect(within(flow).getByText('{"id": <$.messageId>}')).toBeInTheDocument()
})
test('stops a running pipe', async () => {
const user = userEvent.setup()
renderPanel(pipe())
await user.click(await screen.findByRole('button', {name: 'Stop'}))
expect(api.stopCloudResource).toHaveBeenCalledWith('aws', 'pipes', 'orders-pipe')
expect(await screen.findByText(/Pipe stopped/)).toBeInTheDocument()
})
test('sends a test message to an SQS source queue', async () => {
const user = userEvent.setup()
renderPanel(pipe())
const form = await screen.findByRole('form', {name: 'Send test message'})
await user.click(within(form).getByRole('button', {name: 'Send to pipe-src'}))
expect(api.sendSqsMessage).toHaveBeenCalledWith('aws', 'pipe-src', {messageBody: '{"kind": "order", "id": 1}'})
expect(await screen.findByText(/Sent message m-1/)).toBeInTheDocument()
})
test('offers no test message for a stream source, and cannot toggle a pipe mid-change', async () => {
renderPanel(pipe('STARTING', {source: 'arn:aws:kinesis:us-east-1:000000000000:stream/clicks', sourceName: 'clicks (Kinesis stream)', desiredState: 'RUNNING'}))
expect(await screen.findByRole('link', {name: 'clicks (Kinesis stream)'})).toBeInTheDocument()
expect(screen.queryByRole('form', {name: 'Send test message'})).not.toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Start'})).toBeDisabled()
})
@@ -0,0 +1,135 @@
import {ArrowRight, RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {type ReactNode, useEffect, useState} from 'react'
import {Link} from 'react-router-dom'
import {getCloudResource, sendSqsMessage, startCloudResource, stopCloudResource} from '@/api/cloudProxyClient'
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
import {useAccountId} from '@/lib/accountStore'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
/** Where each ARN's service lives in TheCloud, so a step links to the resource behind it. */
const CONSOLE_SERVICE: Record<string, string> = {
sqs: 'messaging',
lambda: 'serverless',
sns: 'notifications',
events: 'events',
states: 'workflows',
kinesis: 'kinesis',
dynamodb: 'nosql',
}
const SAMPLE_MESSAGE = '{"kind": "order", "id": 1}'
export function PipePanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [message, setMessage] = useState(SAMPLE_MESSAGE)
const [notice, setNotice] = useState('')
const pipeKey = ['pipe', accountId, resource?.id]
// The table row is a ListPipes entry; the filter, template and role come from DescribePipe.
const pipeQuery = useQuery({
queryKey: pipeKey,
queryFn: ({signal}) => getCloudResource('aws', 'pipes', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setMessage(SAMPLE_MESSAGE)
setNotice('')
}, [resource?.id])
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: pipeKey}),
queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'pipes')}),
])
const stateMutation = useMutation({
mutationFn: (running: boolean) => running ? startCloudResource('aws', 'pipes', resource?.id ?? '') : stopCloudResource('aws', 'pipes', resource?.id ?? ''),
onSuccess: (_result, running) => {
setNotice(running ? 'Pipe started. It reads the source again.' : 'Pipe stopped. Records wait in the source until it starts again.')
return refresh()
},
})
const sendMutation = useMutation({
mutationFn: (queue: string) => sendSqsMessage('aws', queue, {messageBody: message}),
onSuccess: (sent) => setNotice(`Sent message ${sent.messageId} to the source queue. The pipe picks it up within a few seconds.`),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a pipe</h3><p>Choose a pipe to see where its events flow, start or stop it, or send it a test message.</p></div></section>
}
const pipe = pipeQuery.data ?? resource
const metadata = pipe.metadata
const state = pipe.status ?? 'UNKNOWN'
const running = state === 'RUNNING'
const filters = Array.isArray(metadata.filterPatterns) ? metadata.filterPatterns as string[] : []
const source = text(metadata.source)
const sourceQueue = source.split(':')[2] === 'sqs' ? source.split(':')[5] : ''
const pending = stateMutation.isPending || sendMutation.isPending
const disabled = !runtimeReachable || pending
const error = pipeQuery.error ?? stateMutation.error ?? sendMutation.error
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Pipe ${pipe.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Pipe</p><span className="muted">{state}{metadata.desiredState && metadata.desiredState !== state ? ` · going to ${String(metadata.desiredState)}` : ''}{metadata.description ? ` · ${String(metadata.description)}` : ''}</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={disabled || !['RUNNING', 'STOPPED'].includes(state)} onClick={() => { setNotice(''); stateMutation.mutate(!running) }}>{running ? 'Stop' : 'Start'}</button>
<button className="button" type="button" disabled={!runtimeReachable || pipeQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{pipeQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
{typeof metadata.stateReason === 'string' && metadata.stateReason && <p className="error-text compact-text pipe-reason">{metadata.stateReason}</p>}
<ol className="pipe-flow" aria-label="Event flow">
<PipeStep label="Source" arn={source} name={metadata.sourceName} detail={sourceDetail(metadata)}/>
{filters.length > 0 && <li className="pipe-step"><span className="eyebrow">Filter</span>{filters.map((pattern) => <code key={pattern} className="pipe-code">{pattern}</code>)}<ArrowRight size={14} className="pipe-arrow" aria-hidden/></li>}
{text(metadata.enrichment) && <PipeStep label="Enrichment" arn={text(metadata.enrichment)} name={null}/>}
<PipeStep label="Target" arn={text(metadata.target)} name={metadata.targetName} detail={text(metadata.inputTemplate) ? <>input template <code className="pipe-code">{text(metadata.inputTemplate)}</code></> : undefined}/>
</ol>
{text(metadata.roleArn) && <p className="muted compact-text pipe-role">Runs as <code>{text(metadata.roleArn)}</code></p>}
{sourceQueue && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Try it</p><span className="muted">Send a message to the source queue{filters.length > 0 ? '. Only messages matching the filter reach the target' : ''}.</span></div></div>
<form className="dynamic-form" aria-label="Send test message" onSubmit={(event) => { event.preventDefault(); setNotice(''); sendMutation.mutate(sourceQueue) }}>
<label className="dynamic-field dynamic-field--span"><span>Message body</span>
<textarea className="textarea code-textarea small" rows={3} value={message} onChange={(event) => setMessage(event.target.value)} disabled={disabled} required/>
</label>
<div><button className="button primary" type="submit" disabled={disabled || !message.trim()}>{sendMutation.isPending ? 'Sending' : `Send to ${sourceQueue}`}</button></div>
</form>
</>}
{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>
}
function PipeStep({label, arn, name, detail}: {label: string; arn: string; name: unknown; detail?: ReactNode}) {
const service = CONSOLE_SERVICE[arn.split(':')[2] ?? '']
const title = typeof name === 'string' && name ? name : arn
return <li className="pipe-step">
<span className="eyebrow">{label}</span>
{service ? <Link to={`/cloud-explorer/aws/${service}`} className="mono">{title}</Link> : <span className="mono">{title}</span>}
<small className="muted mono pipe-arn">{arn}</small>
{detail && <small className="muted">{detail}</small>}
<ArrowRight size={14} className="pipe-arrow" aria-hidden/>
</li>
}
function sourceDetail(metadata: Record<string, unknown>): ReactNode {
const parts = [
metadata.batchSize ? `batches of ${String(metadata.batchSize)}` : '',
metadata.startingPosition ? `from ${String(metadata.startingPosition)}` : '',
metadata.topicName ? `topic ${String(metadata.topicName)}` : '',
].filter(Boolean)
return parts.length > 0 ? parts.join(' · ') : undefined
}
function text(value: unknown): string {
return typeof value === 'string' ? value : ''
}
@@ -87,6 +87,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
iac: Layers, iac: Layers,
messaging: MessageSquare, messaging: MessageSquare,
events: Radio, events: Radio,
pipes: Workflow,
email: MessageSquare, email: MessageSquare,
queue: MessageSquare, queue: MessageSquare,
logs: ScrollText, logs: ScrollText,
@@ -121,6 +122,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
ecr: ecrIcon, ecr: ecrIcon,
email: sesIcon, email: sesIcon,
events: eventBridgeIcon, events: eventBridgeIcon,
pipes: eventBridgeIcon,
iac: cloudFormationIcon, iac: cloudFormationIcon,
iam: iamIcon, iam: iamIcon,
kms: kmsIcon, kms: kmsIcon,
@@ -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 status = statusQuery.data
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]) const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data])
// Availability is derived from adapter registration, so an available service // Only a service with a registered adapter has the generic list endpoint. Route
// always has the generic list endpoint. Route shape only decides where the // shape only decides where the card links: a legacy absolute-route page (AWS
// card links, not whether it can be counted -- a legacy absolute-route page // Secrets Manager) is still counted through its adapter, while MSK, available
// (AWS Secrets Manager) is still counted through its adapter. // through its own pre-SPI page, is not listable and is never counted.
const countable = useMemo( const countable = useMemo(
() => services.filter((service) => service.availability === 'available'), () => services.filter((service) => service.availability === 'available' && service.listable),
[services], [services],
) )
@@ -85,7 +85,7 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) {
: undefined, : undefined,
meta: meta:
service.availability === 'available' service.availability === 'available'
? isLegacyPage ? isLegacyPage || !service.listable
? 'open service' ? 'open service'
: serviceMetaLabel(status, counts?.isLoading ?? false, 'resources') : serviceMetaLabel(status, counts?.isLoading ?? false, 'resources')
: 'coming soon', : 'coming soon',
+9
View File
@@ -6610,3 +6610,12 @@ tr[aria-current="true"] td { background: var(--hover-md); }
.metric-chart-table { margin-top: 8px; } .metric-chart-table { margin-top: 8px; }
.metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; } .metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; }
.metric-chart-table table { margin-top: 8px; } .metric-chart-table table { margin-top: 8px; }
/* EventBridge Pipes: source, filter, enrichment and target as one flow. */
.pipe-flow { display: flex; flex-wrap: wrap; gap: 8px 28px; margin: 0; padding: 4px 16px 12px; list-style: none; }
.pipe-step { position: relative; display: grid; gap: 4px; align-content: start; min-width: 0; max-width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--raised); }
.pipe-step .pipe-arrow { position: absolute; top: 50%; right: -22px; transform: translateY(-50%); color: var(--text-3); }
.pipe-step:last-child .pipe-arrow { display: none; }
.pipe-arn, .pipe-code { overflow-wrap: anywhere; }
.pipe-code { font-family: var(--mono); font-size: 11px; white-space: pre-wrap; }
.pipe-role, .pipe-reason { padding: 0 16px 12px; margin: 0; overflow-wrap: anywhere; }
+5
View File
@@ -57,6 +57,11 @@ export interface CloudServiceDescriptor {
iconKey: string iconKey: string
group: ServiceGroup group: ServiceGroup
order: number 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' export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
+18
View File
@@ -88,6 +88,9 @@ importers:
'@aws-sdk/client-lightsail': '@aws-sdk/client-lightsail':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1148.0 version: 3.1148.0
'@aws-sdk/client-pipes':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-rds': '@aws-sdk/client-rds':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1076.0 version: 3.1076.0
@@ -402,6 +405,10 @@ packages:
resolution: {integrity: sha512-sGXHmokMg79uwzFb9aAjC3gSE5LllEJii6aARHbnbDESFGIT73ZbUGBuNDWmF9N4DkkBAPlDeoATOHe4p7Qp5Q==} resolution: {integrity: sha512-sGXHmokMg79uwzFb9aAjC3gSE5LllEJii6aARHbnbDESFGIT73ZbUGBuNDWmF9N4DkkBAPlDeoATOHe4p7Qp5Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-pipes@3.1156.0':
resolution: {integrity: sha512-sHtPycrT80c+bEkQgFJIh+CzpuqjGhhdGtVEQfBU+APmHF01SZEJrbnY7MZEHEJZl0iVgLuasxtlYQO+rhAkoQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-rds-data@3.1144.0': '@aws-sdk/client-rds-data@3.1144.0':
resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==} resolution: {integrity: sha512-6WIjf0rUcT2G//bjsHET1BbQ4sBJ/oR5CEon7ehOI86tr1zQj8XggGqJBuRHvZ+jePabWkTmQcrkpra58Jn2gQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -3196,6 +3203,17 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-pipes@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-rds-data@3.1144.0': '@aws-sdk/client-rds-data@3.1144.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.1