Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 811791603c | |||
| 6acd3656a1 | |||
| 6dd33b8f65 | |||
| f4b3e45f3d |
@@ -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.
|
||||
|
||||
@@ -97,6 +94,7 @@ cd packages/api && bun run scripts/service-matrix.ts
|
||||
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
|
||||
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
|
||||
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
|
||||
| Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No |
|
||||
| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
|
||||
|
||||
Console Home is available for all three clouds.
|
||||
@@ -109,13 +107,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
|
||||
@@ -158,7 +152,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>
|
||||
@@ -168,7 +162,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.
|
||||
@@ -195,8 +189,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>
|
||||
|
||||
@@ -275,7 +269,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.
|
||||
@@ -292,10 +286,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:
|
||||
|
||||
@@ -323,7 +317,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.
|
||||
@@ -376,7 +370,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:
|
||||
|
||||
@@ -388,7 +382,7 @@ The guiding rules are:
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||

|
||||
|
||||
Short implementation notes live in [docs/implementation-notes.md](docs/implementation-notes.md).
|
||||
|
||||
@@ -430,9 +424,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:
|
||||
|
||||
@@ -462,7 +456,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:
|
||||
|
||||
@@ -478,29 +472,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:
|
||||
|
||||
@@ -609,18 +592,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 |
@@ -0,0 +1,147 @@
|
||||
import {describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
DeleteAlarmsCommand,
|
||||
DescribeAlarmHistoryCommand,
|
||||
DescribeAlarmsCommand,
|
||||
DisableAlarmActionsCommand,
|
||||
EnableAlarmActionsCommand,
|
||||
GetMetricDataCommand,
|
||||
ListMetricsCommand,
|
||||
PutCompositeAlarmCommand,
|
||||
PutMetricAlarmCommand,
|
||||
SetAlarmStateCommand,
|
||||
type CloudWatchClient,
|
||||
} from '@aws-sdk/client-cloudwatch'
|
||||
import {AwsMonitoringAdapter} from './AwsMonitoringAdapter'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
type SendResult = Record<string, unknown>
|
||||
|
||||
function stubCloudWatch(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||||
const sent: object[] = []
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
return handler(command)
|
||||
},
|
||||
} as unknown as CloudWatchClient
|
||||
return {adapter: new AwsMonitoringAdapter(client), sent}
|
||||
}
|
||||
|
||||
const UPDATED = new Date('2026-10-25T10:00:00Z')
|
||||
const metricAlarm = {
|
||||
AlarmName: 'high-errors', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:high-errors',
|
||||
StateValue: 'ALARM', StateReason: 'Threshold crossed', StateUpdatedTimestamp: UPDATED, AlarmConfigurationUpdatedTimestamp: UPDATED,
|
||||
ActionsEnabled: true, AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: [],
|
||||
Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
|
||||
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
|
||||
}
|
||||
const compositeAlarm = {
|
||||
AlarmName: 'app-unhealthy', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:app-unhealthy',
|
||||
StateValue: 'OK', AlarmRule: 'ALARM("high-errors")', ActionsEnabled: false, AlarmConfigurationUpdatedTimestamp: UPDATED,
|
||||
}
|
||||
|
||||
describe('AwsMonitoringAdapter alarms', () => {
|
||||
test('lists metric and composite alarms with a readable condition', async () => {
|
||||
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand
|
||||
? {MetricAlarms: [metricAlarm], CompositeAlarms: [compositeAlarm]}
|
||||
: {})
|
||||
|
||||
const alarms = await adapter.list()
|
||||
expect((sent[0] as DescribeAlarmsCommand).input.AlarmTypes).toEqual(['MetricAlarm', 'CompositeAlarm'])
|
||||
expect(alarms.map((alarm) => [alarm.name, alarm.status, alarm.metadata.alarmType])).toEqual([['app-unhealthy', 'OK', 'composite'], ['high-errors', 'ALARM', 'metric']])
|
||||
expect(alarms[1].metadata).toMatchObject({condition: 'Errors Sum > 5 for 2 × 5 min', dimensions: [{name: 'FunctionName', value: 'checkout'}], threshold: 5, periodSeconds: 300})
|
||||
expect(alarms[0].metadata).toMatchObject({condition: 'ALARM("high-errors")', actionsEnabled: false})
|
||||
expect(await adapter.list({search: 'errors'})).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('creates a metric alarm that notifies an SNS topic both ways', async () => {
|
||||
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {MetricAlarms: [metricAlarm]} : {})
|
||||
|
||||
await adapter.create({values: {
|
||||
alarmType: 'metric', name: 'high-errors', namespace: 'App', metricName: 'Errors', dimensions: 'FunctionName=checkout\n',
|
||||
statistic: 'Sum', period: '300', comparison: 'GreaterThanThreshold', threshold: '5', evaluationPeriods: '2',
|
||||
topicArn: 'arn:aws:sns:us-east-1:000000000000:alerts',
|
||||
}})
|
||||
|
||||
expect((sent[0] as PutMetricAlarmCommand).input).toMatchObject({
|
||||
AlarmName: 'high-errors', Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
|
||||
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
|
||||
AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: ['arn:aws:sns:us-east-1:000000000000:alerts'],
|
||||
})
|
||||
})
|
||||
|
||||
test('creates a composite alarm from its rule', async () => {
|
||||
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {CompositeAlarms: [compositeAlarm]} : {})
|
||||
|
||||
const created = await adapter.create({values: {alarmType: 'composite', name: 'app-unhealthy', alarmRule: 'ALARM("high-errors")'}})
|
||||
expect((sent[0] as PutCompositeAlarmCommand).input).toMatchObject({AlarmName: 'app-unhealthy', AlarmRule: 'ALARM("high-errors")', AlarmActions: []})
|
||||
expect(created.metadata.alarmType).toBe('composite')
|
||||
})
|
||||
|
||||
test('checks the alarm before creating it', async () => {
|
||||
const {adapter, sent} = stubCloudWatch(() => ({}))
|
||||
const valid = {alarmType: 'metric', name: 'a', namespace: 'App', metricName: 'Errors', threshold: '5'}
|
||||
|
||||
await expect(adapter.create({values: {...valid, threshold: 'lots'}})).rejects.toThrow(/threshold/)
|
||||
await expect(adapter.create({values: {...valid, metricName: ''}})).rejects.toThrow(/metricName/)
|
||||
await expect(adapter.create({values: {...valid, dimensions: 'FunctionName'}})).rejects.toThrow(/Name=Value/)
|
||||
await expect(adapter.create({values: {...valid, topicArn: 'my-topic'}})).rejects.toThrow(/SNS topic ARN/)
|
||||
await expect(adapter.create({values: {...valid, period: '7'}})).rejects.toThrow(ValidationError)
|
||||
await expect(adapter.create({values: {alarmType: 'composite', name: 'b', alarmRule: ' '}})).rejects.toThrow(/rule/)
|
||||
expect(sent.some((command) => command instanceof PutMetricAlarmCommand || command instanceof PutCompositeAlarmCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('deletes, sets the state and toggles the actions of an alarm', async () => {
|
||||
const {adapter, sent} = stubCloudWatch(() => ({}))
|
||||
|
||||
await adapter.delete('high-errors')
|
||||
await adapter.setAlarmState('high-errors', 'ALARM', '')
|
||||
await adapter.setAlarmActionsEnabled('high-errors', false)
|
||||
await adapter.setAlarmActionsEnabled('high-errors', true)
|
||||
|
||||
expect((sent[0] as DeleteAlarmsCommand).input).toEqual({AlarmNames: ['high-errors']})
|
||||
expect((sent[1] as SetAlarmStateCommand).input).toMatchObject({AlarmName: 'high-errors', StateValue: 'ALARM'})
|
||||
expect((sent[1] as SetAlarmStateCommand).input.StateReason).toContain('TheCloud')
|
||||
expect(sent[2]).toBeInstanceOf(DisableAlarmActionsCommand)
|
||||
expect(sent[3]).toBeInstanceOf(EnableAlarmActionsCommand)
|
||||
await expect(adapter.setAlarmState('high-errors', 'BROKEN' as never, '')).rejects.toThrow(/state/)
|
||||
})
|
||||
|
||||
test('reads the alarm history newest first', async () => {
|
||||
const {adapter, sent} = stubCloudWatch(() => ({AlarmHistoryItems: [{Timestamp: UPDATED, HistoryItemType: 'StateUpdate', HistorySummary: 'Alarm updated from OK to ALARM'}]}))
|
||||
|
||||
expect(await adapter.describeAlarmHistory('high-errors')).toEqual([{timestamp: '2026-10-25T10:00:00.000Z', type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
|
||||
expect((sent[0] as DescribeAlarmHistoryCommand).input).toMatchObject({AlarmName: 'high-errors', ScanBy: 'TimestampDescending'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsMonitoringAdapter metrics', () => {
|
||||
test('lists metrics sorted by namespace and filters them', async () => {
|
||||
const {adapter} = stubCloudWatch((command) => command instanceof ListMetricsCommand
|
||||
? {Metrics: [
|
||||
{Namespace: 'AWS/SQS', MetricName: 'NumberOfMessagesSent', Dimensions: [{Name: 'QueueName', Value: 'orders'}]},
|
||||
{Namespace: 'App', MetricName: 'Errors', Dimensions: []},
|
||||
]}
|
||||
: {})
|
||||
|
||||
expect((await adapter.listMetrics()).map((metric) => `${metric.namespace}/${metric.metricName}`)).toEqual(['App/Errors', 'AWS/SQS/NumberOfMessagesSent'])
|
||||
expect(await adapter.listMetrics({search: 'orders'})).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('charts a metric with a period that suits the range', async () => {
|
||||
const {adapter, sent} = stubCloudWatch((command) => command instanceof GetMetricDataCommand
|
||||
? {MetricDataResults: [{Id: 'm1', Label: 'Errors', Timestamps: [new Date('2026-10-25T10:01:00Z'), new Date('2026-10-25T10:00:00Z')], Values: [7, 3]}]}
|
||||
: {})
|
||||
|
||||
const series = await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 60, dimensions: [{name: 'FunctionName', value: 'checkout'}]})
|
||||
expect(series).toEqual({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00.000Z', value: 3}, {timestamp: '2026-10-25T10:01:00.000Z', value: 7}]})
|
||||
const query = (sent[0] as GetMetricDataCommand).input.MetricDataQueries![0]
|
||||
expect(query.MetricStat).toEqual({Metric: {Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}]}, Period: 60, Stat: 'Sum'})
|
||||
|
||||
await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 24 * 60})
|
||||
expect((sent[1] as GetMetricDataCommand).input.MetricDataQueries![0].MetricStat!.Period).toBe(300)
|
||||
await expect(adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 1})).rejects.toThrow(/rangeMinutes/)
|
||||
await expect(adapter.getMetricSeries({namespace: '', metricName: 'Errors'})).rejects.toThrow(/namespace/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
DeleteAlarmsCommand,
|
||||
DescribeAlarmHistoryCommand,
|
||||
DescribeAlarmsCommand,
|
||||
DisableAlarmActionsCommand,
|
||||
EnableAlarmActionsCommand,
|
||||
GetMetricDataCommand,
|
||||
ListMetricsCommand,
|
||||
PutCompositeAlarmCommand,
|
||||
PutMetricAlarmCommand,
|
||||
SetAlarmStateCommand,
|
||||
type CloudWatchClient,
|
||||
type ComparisonOperator,
|
||||
type CompositeAlarm,
|
||||
type MetricAlarm,
|
||||
type Statistic,
|
||||
} from '@aws-sdk/client-cloudwatch'
|
||||
import {
|
||||
ALARM_COMPARISONS,
|
||||
ALARM_NAME_PATTERN,
|
||||
ALARM_PERIODS,
|
||||
ALARM_STATES,
|
||||
ALARM_STATISTICS,
|
||||
ALARM_TYPES,
|
||||
awsMonitoringSchema,
|
||||
} from '../cloud-spi/monitoringSchema'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import type {
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
CloudResource,
|
||||
CloudServiceAdapter,
|
||||
CreateResourceInput,
|
||||
MetricDimension,
|
||||
MetricSeries,
|
||||
MetricSeriesQuery,
|
||||
MetricSummary,
|
||||
ResourceQuery,
|
||||
ServiceSchema,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
/** CloudWatch's page and request limits. */
|
||||
const ALARM_PAGE_LIMIT = 100
|
||||
const METRIC_LIST_CAP = 500
|
||||
const HISTORY_LIMIT = 25
|
||||
const MAX_DIMENSIONS = 30
|
||||
const MAX_RANGE_MINUTES = 14 * 24 * 60
|
||||
const SYMBOL: Record<string, string> = {
|
||||
GreaterThanThreshold: '>',
|
||||
GreaterThanOrEqualToThreshold: '>=',
|
||||
LessThanThreshold: '<',
|
||||
LessThanOrEqualToThreshold: '<=',
|
||||
}
|
||||
|
||||
/**
|
||||
* Talks to CloudWatch metrics and alarms. Alarms, metric and composite, are the resources;
|
||||
* metrics are browsed and charted through the monitoring actions.
|
||||
*
|
||||
* Verified against TheCloud's runtime: alarm actions publish to SNS on every state change,
|
||||
* `SetAlarmState` changes the state until the next evaluation, and `PutMetricData` datums
|
||||
* are returned by `GetMetricData` straight away.
|
||||
*/
|
||||
export class AwsMonitoringAdapter implements CloudServiceAdapter {
|
||||
readonly cloud = 'aws' as const
|
||||
readonly service = 'monitoring' as const
|
||||
|
||||
constructor(private readonly cloudwatch: CloudWatchClient) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsMonitoringSchema()
|
||||
}
|
||||
|
||||
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
|
||||
const metricAlarms: MetricAlarm[] = []
|
||||
const compositeAlarms: CompositeAlarm[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({
|
||||
AlarmTypes: ['MetricAlarm', 'CompositeAlarm'],
|
||||
MaxRecords: ALARM_PAGE_LIMIT,
|
||||
...(nextToken ? {NextToken: nextToken} : {}),
|
||||
}))
|
||||
metricAlarms.push(...(res.MetricAlarms ?? []))
|
||||
compositeAlarms.push(...(res.CompositeAlarms ?? []))
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return [...metricAlarms.map(metricAlarmResource), ...compositeAlarms.map(compositeAlarmResource)]
|
||||
.filter((alarm) => !search || [alarm.name, String(alarm.metadata.metricName ?? ''), String(alarm.metadata.namespace ?? '')].some((value) => value.toLowerCase().includes(search)))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
async get(name: string): Promise<CloudResource | null> {
|
||||
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({AlarmNames: [name], AlarmTypes: ['MetricAlarm', 'CompositeAlarm']}))
|
||||
const metric = res.MetricAlarms?.[0]
|
||||
if (metric) return metricAlarmResource(metric)
|
||||
const composite = res.CompositeAlarms?.[0]
|
||||
return composite ? compositeAlarmResource(composite) : null
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const values = input.values
|
||||
const type = oneOf(values.alarmType ?? 'metric', ALARM_TYPES, 'alarmType')
|
||||
const name = text(values.name)
|
||||
if (!new RegExp(ALARM_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 255 characters, without control characters')
|
||||
const description = text(values.description) || undefined
|
||||
const topicArn = text(values.topicArn)
|
||||
if (topicArn && !/^arn:aws[a-z-]*:sns:[a-z0-9-]+:\d{12}:[A-Za-z0-9_-]{1,256}(\.fifo)?$/.test(topicArn)) {
|
||||
throw new ValidationError('topicArn must be an SNS topic ARN, such as arn:aws:sns:us-east-1:000000000000:alerts')
|
||||
}
|
||||
const actions = topicArn ? [topicArn] : []
|
||||
|
||||
if (type === 'composite') {
|
||||
const rule = text(values.alarmRule)
|
||||
if (!rule) throw new ValidationError('Write the rule, such as ALARM("high-errors")')
|
||||
if (rule.length > 10240) throw new ValidationError('The rule must be at most 10240 characters')
|
||||
await this.cloudwatch.send(new PutCompositeAlarmCommand({
|
||||
AlarmName: name,
|
||||
AlarmDescription: description,
|
||||
AlarmRule: rule,
|
||||
AlarmActions: actions,
|
||||
OKActions: actions,
|
||||
ActionsEnabled: true,
|
||||
}))
|
||||
} else {
|
||||
const namespace = text(values.namespace)
|
||||
const metricName = text(values.metricName)
|
||||
if (!namespace) throw new ValidationError('namespace is required, such as AWS/Lambda')
|
||||
if (!metricName) throw new ValidationError('metricName is required, such as Errors')
|
||||
const threshold = Number(text(values.threshold))
|
||||
if (text(values.threshold) === '' || !Number.isFinite(threshold)) throw new ValidationError('threshold must be a number')
|
||||
const evaluationPeriods = Number(text(values.evaluationPeriods) || '1')
|
||||
if (!Number.isInteger(evaluationPeriods) || evaluationPeriods < 1) throw new ValidationError('evaluationPeriods must be a whole number from 1')
|
||||
await this.cloudwatch.send(new PutMetricAlarmCommand({
|
||||
AlarmName: name,
|
||||
AlarmDescription: description,
|
||||
Namespace: namespace,
|
||||
MetricName: metricName,
|
||||
Dimensions: parseDimensions(values.dimensions).map((dimension) => ({Name: dimension.name, Value: dimension.value})),
|
||||
Statistic: oneOf(values.statistic || 'Average', ALARM_STATISTICS, 'statistic') as Statistic,
|
||||
Period: Number(oneOf(String(values.period || '300'), ALARM_PERIODS, 'period')),
|
||||
EvaluationPeriods: evaluationPeriods,
|
||||
Threshold: threshold,
|
||||
ComparisonOperator: oneOf(values.comparison || 'GreaterThanThreshold', ALARM_COMPARISONS, 'comparison') as ComparisonOperator,
|
||||
TreatMissingData: 'missing',
|
||||
AlarmActions: actions,
|
||||
OKActions: actions,
|
||||
ActionsEnabled: true,
|
||||
}))
|
||||
}
|
||||
const created = await this.get(name)
|
||||
if (!created) throw new NotFoundError(`CloudWatch did not return the alarm ${name}`)
|
||||
return created
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
await this.cloudwatch.send(new DeleteAlarmsCommand({AlarmNames: [name]}))
|
||||
}
|
||||
|
||||
async listMetrics(query: {namespace?: string; search?: string} = {}): Promise<MetricSummary[]> {
|
||||
const metrics: MetricSummary[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const res = await this.cloudwatch.send(new ListMetricsCommand({
|
||||
...(query.namespace ? {Namespace: query.namespace} : {}),
|
||||
...(nextToken ? {NextToken: nextToken} : {}),
|
||||
}))
|
||||
for (const metric of res.Metrics ?? []) {
|
||||
metrics.push({
|
||||
namespace: metric.Namespace ?? '',
|
||||
metricName: metric.MetricName ?? '',
|
||||
dimensions: (metric.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''})),
|
||||
})
|
||||
}
|
||||
nextToken = res.NextToken || undefined
|
||||
} while (nextToken && metrics.length < METRIC_LIST_CAP)
|
||||
|
||||
const search = query.search?.trim().toLowerCase()
|
||||
return metrics.slice(0, METRIC_LIST_CAP)
|
||||
.filter((metric) => !search || [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => dimension.value)].some((value) => value.toLowerCase().includes(search)))
|
||||
.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.metricName.localeCompare(b.metricName))
|
||||
}
|
||||
|
||||
async getMetricSeries(query: MetricSeriesQuery): Promise<MetricSeries> {
|
||||
const namespace = text(query?.namespace)
|
||||
const metricName = text(query.metricName)
|
||||
if (!namespace || !metricName) throw new ValidationError('namespace and metricName are required')
|
||||
const statistic = oneOf(query.statistic || 'Average', ALARM_STATISTICS, 'statistic')
|
||||
const rangeMinutes = Number(query.rangeMinutes ?? 60)
|
||||
if (!Number.isFinite(rangeMinutes) || rangeMinutes < 5 || rangeMinutes > MAX_RANGE_MINUTES) throw new ValidationError('rangeMinutes must be between 5 and 20160')
|
||||
// A point every minute for short ranges, coarser for long ones, staying near 60-300 points.
|
||||
const periodSeconds = Number(query.periodSeconds) || (rangeMinutes <= 180 ? 60 : rangeMinutes <= 1440 ? 300 : 3600)
|
||||
if (![60, 300, 900, 3600].includes(periodSeconds)) throw new ValidationError('periodSeconds must be 60, 300, 900 or 3600')
|
||||
const dimensions = (query.dimensions ?? []).map((dimension) => ({Name: text(dimension?.name), Value: text(dimension?.value)}))
|
||||
if (dimensions.some((dimension) => !dimension.Name)) throw new ValidationError('Every dimension needs a name')
|
||||
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - rangeMinutes * 60_000)
|
||||
const res = await this.cloudwatch.send(new GetMetricDataCommand({
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
ScanBy: 'TimestampAscending',
|
||||
MetricDataQueries: [{
|
||||
Id: 'm1',
|
||||
ReturnData: true,
|
||||
MetricStat: {Metric: {Namespace: namespace, MetricName: metricName, Dimensions: dimensions}, Period: periodSeconds, Stat: statistic},
|
||||
}],
|
||||
}))
|
||||
const result = res.MetricDataResults?.[0]
|
||||
const timestamps = result?.Timestamps ?? []
|
||||
const points = timestamps.map((timestamp, index) => ({timestamp: timestamp.toISOString(), value: result?.Values?.[index] ?? 0}))
|
||||
.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
||||
return {label: result?.Label || metricName, statistic, periodSeconds, points}
|
||||
}
|
||||
|
||||
async describeAlarmHistory(name: string): Promise<AlarmHistoryItem[]> {
|
||||
const res = await this.cloudwatch.send(new DescribeAlarmHistoryCommand({AlarmName: name, MaxRecords: HISTORY_LIMIT, ScanBy: 'TimestampDescending'}))
|
||||
return (res.AlarmHistoryItems ?? []).map((item) => ({
|
||||
timestamp: item.Timestamp?.toISOString() ?? null,
|
||||
type: item.HistoryItemType ?? '',
|
||||
summary: item.HistorySummary ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
/** Changes the state until the next evaluation, which is how alarm actions are tested on AWS. */
|
||||
async setAlarmState(name: string, state: AlarmStateValue, reason: string): Promise<void> {
|
||||
const value = oneOf(state, ALARM_STATES, 'state')
|
||||
const why = text(reason) || `Set to ${value} from TheCloud to test the alarm's actions`
|
||||
if (why.length > 1023) throw new ValidationError('reason must be at most 1023 characters')
|
||||
await this.cloudwatch.send(new SetAlarmStateCommand({AlarmName: name, StateValue: value, StateReason: why}))
|
||||
}
|
||||
|
||||
async setAlarmActionsEnabled(name: string, enabled: boolean): Promise<void> {
|
||||
if (typeof enabled !== 'boolean') throw new ValidationError('enabled must be true or false')
|
||||
await this.cloudwatch.send(enabled ? new EnableAlarmActionsCommand({AlarmNames: [name]}) : new DisableAlarmActionsCommand({AlarmNames: [name]}))
|
||||
}
|
||||
}
|
||||
|
||||
function metricAlarmResource(alarm: MetricAlarm): CloudResource {
|
||||
const dimensions = (alarm.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''}))
|
||||
const period = alarm.Period ?? 0
|
||||
return {
|
||||
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
|
||||
metadata: {
|
||||
alarmType: 'metric',
|
||||
arn: alarm.AlarmArn ?? null,
|
||||
description: alarm.AlarmDescription ?? null,
|
||||
stateReason: alarm.StateReason ?? null,
|
||||
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
|
||||
actionsEnabled: alarm.ActionsEnabled ?? true,
|
||||
alarmActions: alarm.AlarmActions ?? [],
|
||||
okActions: alarm.OKActions ?? [],
|
||||
namespace: alarm.Namespace ?? null,
|
||||
metricName: alarm.MetricName ?? null,
|
||||
dimensions,
|
||||
statistic: alarm.Statistic ?? alarm.ExtendedStatistic ?? null,
|
||||
periodSeconds: period || null,
|
||||
threshold: alarm.Threshold ?? null,
|
||||
comparison: alarm.ComparisonOperator ?? null,
|
||||
evaluationPeriods: alarm.EvaluationPeriods ?? null,
|
||||
condition: alarm.MetricName
|
||||
? `${alarm.MetricName} ${alarm.Statistic ?? ''} ${SYMBOL[alarm.ComparisonOperator ?? ''] ?? alarm.ComparisonOperator ?? ''} ${alarm.Threshold ?? ''} for ${alarm.EvaluationPeriods ?? 1} × ${periodLabel(period)}`.replace(/\s+/g, ' ').trim()
|
||||
: 'Metric math',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function compositeAlarmResource(alarm: CompositeAlarm): CloudResource {
|
||||
return {
|
||||
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
|
||||
metadata: {
|
||||
alarmType: 'composite',
|
||||
arn: alarm.AlarmArn ?? null,
|
||||
description: alarm.AlarmDescription ?? null,
|
||||
stateReason: alarm.StateReason ?? null,
|
||||
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
|
||||
actionsEnabled: alarm.ActionsEnabled ?? true,
|
||||
alarmActions: alarm.AlarmActions ?? [],
|
||||
okActions: alarm.OKActions ?? [],
|
||||
alarmRule: alarm.AlarmRule ?? null,
|
||||
condition: alarm.AlarmRule ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function baseResource(name: string, arn: string | undefined, state: string | undefined, updated: Date | undefined) {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
cloud: 'aws' as const,
|
||||
service: 'monitoring' as const,
|
||||
type: 'alarm',
|
||||
region: arn?.split(':')[3] || null,
|
||||
createdAt: updated?.toISOString() ?? null,
|
||||
status: state ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/** One Name=Value per line, as the create form takes it. */
|
||||
function parseDimensions(value: unknown): MetricDimension[] {
|
||||
const lines = typeof value === 'string' ? value.split('\n').map((line) => line.trim()).filter(Boolean) : []
|
||||
if (lines.length > MAX_DIMENSIONS) throw new ValidationError(`An alarm takes at most ${MAX_DIMENSIONS} dimensions`)
|
||||
return lines.map((line) => {
|
||||
const index = line.indexOf('=')
|
||||
const name = index > 0 ? line.slice(0, index).trim() : ''
|
||||
const dimensionValue = index > 0 ? line.slice(index + 1).trim() : ''
|
||||
if (!name || !dimensionValue) throw new ValidationError(`Write each dimension as Name=Value (got "${line}")`)
|
||||
return {name, value: dimensionValue}
|
||||
})
|
||||
}
|
||||
|
||||
function periodLabel(seconds: number): string {
|
||||
if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} h`
|
||||
if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`
|
||||
return `${seconds} s`
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T {
|
||||
if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T
|
||||
throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : ''
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
|
||||
|
||||
export const ALARM_TYPES = ['metric', 'composite'] as const
|
||||
export const ALARM_STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'] as const
|
||||
export const ALARM_COMPARISONS = [
|
||||
'GreaterThanThreshold',
|
||||
'GreaterThanOrEqualToThreshold',
|
||||
'LessThanThreshold',
|
||||
'LessThanOrEqualToThreshold',
|
||||
] as const
|
||||
export const ALARM_PERIODS = ['60', '300', '900', '3600'] as const
|
||||
export const ALARM_STATES = ['OK', 'ALARM', 'INSUFFICIENT_DATA'] as const
|
||||
/** CloudWatch's alarm name rule: 1-255 characters, no control characters. */
|
||||
export const ALARM_NAME_PATTERN = '^[^\\x00-\\x1f\\x7f]{1,255}$'
|
||||
|
||||
const alarmColumns: TableColumnSchema[] = [
|
||||
{name: 'name', label: 'Alarm'},
|
||||
{name: 'status', label: 'State', format: 'badge'},
|
||||
{name: 'alarmType', label: 'Type', path: 'metadata.alarmType'},
|
||||
{name: 'condition', label: 'Condition', path: 'metadata.condition', format: 'code', emptyText: '—'},
|
||||
{name: 'stateUpdatedAt', label: 'State changed', path: 'metadata.stateUpdatedAt', format: 'relative'},
|
||||
{name: 'actionsEnabled', label: 'Actions', path: 'metadata.actionsEnabled', format: 'boolean'},
|
||||
]
|
||||
|
||||
const alarmFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
|
||||
|
||||
const metricOnly = {field: 'alarmType', values: ['metric']}
|
||||
|
||||
export function awsMonitoringSchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'monitoring',
|
||||
displayName: 'Amazon CloudWatch alarms',
|
||||
fields: [
|
||||
{
|
||||
name: 'alarmType',
|
||||
label: 'Alarm type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'metric',
|
||||
options: [
|
||||
{label: 'Metric alarm (a threshold on one metric)', value: 'metric'},
|
||||
{label: 'Composite alarm (a rule over other alarms)', value: 'composite'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Alarm name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
validation: {pattern: ALARM_NAME_PATTERN, message: 'Use up to 255 characters, without control characters'},
|
||||
},
|
||||
{name: 'description', label: 'Description', type: 'text', required: false},
|
||||
{name: 'namespace', label: 'Namespace', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example AWS/Lambda, AWS/SQS or your own, such as MyApp.'},
|
||||
{name: 'metricName', label: 'Metric name', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example Errors or Duration.'},
|
||||
{
|
||||
name: 'dimensions',
|
||||
label: 'Dimensions',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
visibleWhen: metricOnly,
|
||||
description: 'One Name=Value per line, for example FunctionName=checkout. Leave empty for the metric across all dimensions.',
|
||||
},
|
||||
{
|
||||
name: 'statistic',
|
||||
label: 'Statistic',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'Average',
|
||||
visibleWhen: metricOnly,
|
||||
options: ALARM_STATISTICS.map((value) => ({label: value, value})),
|
||||
},
|
||||
{
|
||||
name: 'period',
|
||||
label: 'Period',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: '300',
|
||||
visibleWhen: metricOnly,
|
||||
options: [{label: '1 minute', value: '60'}, {label: '5 minutes', value: '300'}, {label: '15 minutes', value: '900'}, {label: '1 hour', value: '3600'}],
|
||||
},
|
||||
{
|
||||
name: 'comparison',
|
||||
label: 'Condition',
|
||||
type: 'select',
|
||||
required: false,
|
||||
defaultValue: 'GreaterThanThreshold',
|
||||
visibleWhen: metricOnly,
|
||||
options: [
|
||||
{label: 'Greater than the threshold', value: 'GreaterThanThreshold'},
|
||||
{label: 'Greater than or equal to the threshold', value: 'GreaterThanOrEqualToThreshold'},
|
||||
{label: 'Less than the threshold', value: 'LessThanThreshold'},
|
||||
{label: 'Less than or equal to the threshold', value: 'LessThanOrEqualToThreshold'},
|
||||
],
|
||||
},
|
||||
{name: 'threshold', label: 'Threshold', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, validation: {pattern: '^-?\\d+(\\.\\d+)?$', message: 'Use a number'}},
|
||||
{name: 'evaluationPeriods', label: 'Periods to breach', type: 'text', required: false, defaultValue: '1', visibleWhen: metricOnly, description: 'How many periods in a row must breach before the alarm fires.', validation: {pattern: '^[1-9]\\d{0,2}$', message: 'Use a whole number from 1'}},
|
||||
{
|
||||
name: 'alarmRule',
|
||||
label: 'Rule',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
requiredWhen: {field: 'alarmType', equals: 'composite'},
|
||||
visibleWhen: {field: 'alarmType', values: ['composite']},
|
||||
description: 'For example ALARM("high-errors") OR ALARM("slow-checkout"). AND, OR, NOT and parentheses work; OK() and INSUFFICIENT_DATA() too.',
|
||||
},
|
||||
{name: 'topicArn', label: 'Notify SNS topic', type: 'text', required: false, description: 'Optional. An SNS topic ARN told when the alarm fires and when it recovers.'},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {
|
||||
resourceActions: [
|
||||
{name: 'list', label: 'List alarms', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'create', label: 'Create alarm', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'delete', label: 'Delete alarm', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'inspect', label: 'Inspect alarm', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
monitoringActions: [
|
||||
{name: 'listMetrics', label: 'Browse metrics', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'getMetricData', label: 'Chart a metric', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'alarmHistory', label: 'Show alarm history', enabled: false, status: 'blocked', reason: "TheCloud's runtime does not implement DescribeAlarmHistory yet.", runtimeRequired: true},
|
||||
{name: 'setAlarmState', label: 'Set an alarm state to test its actions', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'setAlarmActions', label: 'Turn alarm actions on or off', enabled: true, status: 'available', runtimeRequired: true},
|
||||
],
|
||||
},
|
||||
filters: alarmFilters,
|
||||
columns: alarmColumns,
|
||||
}
|
||||
}
|
||||
|
||||
export function monitoringSchemaFor(cloud: CloudProvider): ServiceSchema | null {
|
||||
return cloud === 'aws' ? awsMonitoringSchema() : null
|
||||
}
|
||||
@@ -281,6 +281,13 @@ export const SERVICE_CATALOG = {
|
||||
group: 'Integration',
|
||||
order: 20,
|
||||
},
|
||||
monitoring: {
|
||||
displayName: 'Monitoring',
|
||||
displayNameByCloud: {aws: 'CloudWatch'},
|
||||
iconKey: 'monitoring',
|
||||
group: 'Observability',
|
||||
order: 5,
|
||||
},
|
||||
logs: {
|
||||
displayName: 'Logs',
|
||||
displayNameByCloud: {aws: 'CloudWatch Logs'},
|
||||
|
||||
@@ -181,6 +181,7 @@ export type UserPoolActionName =
|
||||
| 'describeClient'
|
||||
| 'updateClientOAuth'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
|
||||
export interface CapabilitySchema<TAction extends string> {
|
||||
@@ -226,6 +227,7 @@ export interface ServiceSchema {
|
||||
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
|
||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
|
||||
// Child levels advertise separately from the resource level because a
|
||||
// store can be readable at the leaf and writable at the collection —
|
||||
// CloudWatch Logs creates streams but cannot delete an event.
|
||||
@@ -457,6 +459,42 @@ export interface UserPoolSignInTestInput {
|
||||
}
|
||||
|
||||
/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */
|
||||
export interface MetricDimension {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface MetricSummary {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions: MetricDimension[]
|
||||
}
|
||||
|
||||
export interface MetricSeriesQuery {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions?: MetricDimension[]
|
||||
statistic?: string
|
||||
periodSeconds?: number
|
||||
/** How far back from now, in minutes. */
|
||||
rangeMinutes?: number
|
||||
}
|
||||
|
||||
export interface MetricSeries {
|
||||
label: string
|
||||
statistic: string
|
||||
periodSeconds: number
|
||||
points: {timestamp: string; value: number}[]
|
||||
}
|
||||
|
||||
export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA'
|
||||
|
||||
export interface AlarmHistoryItem {
|
||||
timestamp: string | null
|
||||
type: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
@@ -1006,6 +1044,11 @@ export interface CloudServiceAdapter {
|
||||
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
|
||||
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
|
||||
setDistributionEnabled?(distributionId: string, enabled: boolean): Promise<CloudResource>
|
||||
listMetrics?(query: {namespace?: string; search?: string}): Promise<MetricSummary[]>
|
||||
getMetricSeries?(query: MetricSeriesQuery): Promise<MetricSeries>
|
||||
describeAlarmHistory?(alarmName: string): Promise<AlarmHistoryItem[]>
|
||||
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
|
||||
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
|
||||
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
|
||||
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
|
||||
/** Clears the provider's locally captured email inbox, if it exposes one. */
|
||||
|
||||
@@ -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, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Guards the schema-to-implementation contract for every registered adapter.
|
||||
@@ -47,6 +47,14 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
|
||||
deleteFargateProfile: 'deleteKubernetesFargateProfile',
|
||||
}
|
||||
|
||||
const METHOD_FOR_MONITORING_ACTION: Record<MonitoringActionName, keyof CloudServiceAdapter> = {
|
||||
listMetrics: 'listMetrics',
|
||||
getMetricData: 'getMetricSeries',
|
||||
alarmHistory: 'describeAlarmHistory',
|
||||
setAlarmState: 'setAlarmState',
|
||||
setAlarmActions: 'setAlarmActionsEnabled',
|
||||
}
|
||||
|
||||
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
|
||||
setEnabled: 'setDistributionEnabled',
|
||||
createInvalidation: 'createDistributionInvalidation',
|
||||
@@ -147,6 +155,15 @@ describe('registered adapters honour their schema', () => {
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const monitoringClaims = (adapter.schema().capabilities?.monitoringActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of monitoringClaims) {
|
||||
const method = METHOD_FOR_MONITORING_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]
|
||||
@@ -169,6 +186,7 @@ describe('registered adapters honour their schema', () => {
|
||||
...(adapter.schema().capabilities?.kubernetesActions ?? []),
|
||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||
...(adapter.schema().capabilities?.distributionActions ?? []),
|
||||
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||
...(adapter.schema().capabilities?.itemActions ?? []),
|
||||
]
|
||||
|
||||
@@ -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 {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
|
||||
import {awsClientsForAccount, resolveAccountId} from './aws'
|
||||
import {createEc2Service} from './services/ec2'
|
||||
import {createEksService} from './services/eks'
|
||||
@@ -102,6 +103,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsUserPoolsAdapter(clients.cognito),
|
||||
new AwsDnsAdapter(clients.route53),
|
||||
new AwsCdnAdapter(clients.cloudfront, clients.s3),
|
||||
new AwsMonitoringAdapter(clients.cloudwatch),
|
||||
new AzureStorageAdapter(),
|
||||
new AzureServiceBusAdapter(),
|
||||
new AzureDatabaseAdapter(),
|
||||
|
||||
@@ -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 {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
|
||||
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
|
||||
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
|
||||
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
|
||||
@@ -770,6 +771,33 @@ describe('cloud schema routes', () => {
|
||||
expect(calls).toEqual([['us-east-1_x', 'c1', input]])
|
||||
})
|
||||
|
||||
test('routes CloudWatch metrics, history, alarm state and actions to the adapter', async () => {
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
service: 'monitoring',
|
||||
schema: awsMonitoringSchema,
|
||||
listMetrics: async (query) => { calls.push(['metrics', query]); return [{namespace: 'App', metricName: 'Errors', dimensions: []}] },
|
||||
getMetricSeries: async (query) => { calls.push(['series', query.metricName, query.rangeMinutes]); return {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: []} },
|
||||
describeAlarmHistory: async (name) => [{timestamp: null, type: 'StateUpdate', summary: `${name} changed`}],
|
||||
setAlarmState: async (name, state, reason) => { calls.push(['state', name, state, reason]) },
|
||||
setAlarmActionsEnabled: async (name, enabled) => { calls.push(['actions', name, enabled]) },
|
||||
})])
|
||||
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
|
||||
const base = '/api/clouds/aws/services/monitoring'
|
||||
|
||||
expect(await (await app.request(`${base}/metrics?namespace=App`)).json()).toEqual([{namespace: 'App', metricName: 'Errors', dimensions: []}])
|
||||
expect((await (await app.request(`${base}/metric-data`, json('POST', {namespace: 'App', metricName: 'Errors', rangeMinutes: 60}))).json()).label).toBe('Errors')
|
||||
expect((await (await app.request(`${base}/resources/high-errors/history`)).json())[0].summary).toBe('high-errors changed')
|
||||
expect((await app.request(`${base}/resources/high-errors/state`, json('PUT', {state: 'ALARM', reason: 'test'}))).status).toBe(200)
|
||||
expect((await app.request(`${base}/resources/high-errors/actions`, json('PUT', {enabled: false}))).status).toBe(200)
|
||||
expect(calls).toEqual([
|
||||
['metrics', {namespace: 'App', search: undefined}],
|
||||
['series', 'Errors', 60],
|
||||
['state', 'high-errors', 'ALARM', 'test'],
|
||||
['actions', 'high-errors', false],
|
||||
])
|
||||
})
|
||||
|
||||
test('enables a distribution and invalidates its paths through the CloudFront adapter', async () => {
|
||||
const calls: unknown[] = []
|
||||
const app = appWithRoutes([mockAdapter('aws', {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Hono} from 'hono'
|
||||
import type {Context} from 'hono'
|
||||
import type {
|
||||
AlarmStateValue,
|
||||
CloudProvider,
|
||||
CloudServiceType,
|
||||
CreateDatabaseSnapshotInput,
|
||||
@@ -461,6 +462,49 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
return withRuntime(c, async () => c.json(await svc(c).updateUserPoolClientOAuth(cloud, c.req.param('id'), c.req.param('clientId'), await c.req.json())))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/monitoring/metrics', 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).listMetrics(cloud, {namespace: c.req.query('namespace') || undefined, search: c.req.query('search') || undefined})))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/monitoring/metric-data', 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).getMetricSeries(cloud, await c.req.json())))
|
||||
})
|
||||
|
||||
app.get('/:cloud/services/monitoring/resources/:id/history', 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).describeAlarmHistory(cloud, c.req.param('id'))))
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/monitoring/resources/:id/state', 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<{state?: AlarmStateValue; reason?: string}>()
|
||||
await svc(c).setAlarmState(cloud, c.req.param('id'), body.state as AlarmStateValue, body.reason ?? '')
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/monitoring/resources/:id/actions', 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<{enabled?: boolean}>()
|
||||
await svc(c).setAlarmActionsEnabled(cloud, c.req.param('id'), body.enabled as boolean)
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/cdn/resources/:id/enabled', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
|
||||
|
||||
@@ -83,6 +83,11 @@ import type {
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
MetricSeriesQuery,
|
||||
MetricSummary,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolUserAction,
|
||||
} from '../cloud-spi/types'
|
||||
@@ -834,6 +839,36 @@ export class CloudProxyService {
|
||||
return adapter.testUserPoolSignIn(poolId, input)
|
||||
}
|
||||
|
||||
async listMetrics(cloud: CloudProvider, query: {namespace?: string; search?: string}): Promise<MetricSummary[]> {
|
||||
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||
if (!adapter.listMetrics) throw new NotSupportedError(`Metrics are not supported for ${cloud}/monitoring`)
|
||||
return adapter.listMetrics(query)
|
||||
}
|
||||
|
||||
async getMetricSeries(cloud: CloudProvider, query: MetricSeriesQuery): Promise<MetricSeries> {
|
||||
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||
if (!adapter.getMetricSeries) throw new NotSupportedError(`Metric data is not supported for ${cloud}/monitoring`)
|
||||
return adapter.getMetricSeries(query)
|
||||
}
|
||||
|
||||
async describeAlarmHistory(cloud: CloudProvider, alarmName: string): Promise<AlarmHistoryItem[]> {
|
||||
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||
if (!adapter.describeAlarmHistory) throw new NotSupportedError(`Alarm history is not supported for ${cloud}/monitoring`)
|
||||
return adapter.describeAlarmHistory(alarmName)
|
||||
}
|
||||
|
||||
async setAlarmState(cloud: CloudProvider, alarmName: string, state: AlarmStateValue, reason: string): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||
if (!adapter.setAlarmState) throw new NotSupportedError(`Setting alarm states is not supported for ${cloud}/monitoring`)
|
||||
await adapter.setAlarmState(alarmName, state, reason)
|
||||
}
|
||||
|
||||
async setAlarmActionsEnabled(cloud: CloudProvider, alarmName: string, enabled: boolean): Promise<void> {
|
||||
const adapter = this.requireAdapter(cloud, 'monitoring')
|
||||
if (!adapter.setAlarmActionsEnabled) throw new NotSupportedError(`Alarm actions are not supported for ${cloud}/monitoring`)
|
||||
await adapter.setAlarmActionsEnabled(alarmName, enabled)
|
||||
}
|
||||
|
||||
async setDistributionEnabled(cloud: CloudProvider, distributionId: string, enabled: boolean): Promise<CloudResource> {
|
||||
const adapter = this.requireAdapter(cloud, 'cdn')
|
||||
if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`)
|
||||
|
||||
@@ -130,6 +130,13 @@ export const apiEndpointKeys = {
|
||||
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
||||
moveTasks: "clouds.services.messaging.move-tasks",
|
||||
},
|
||||
monitoring: {
|
||||
metrics: "clouds.services.monitoring.metrics",
|
||||
metricData: "clouds.services.monitoring.metric-data",
|
||||
history: "clouds.services.monitoring.history",
|
||||
state: "clouds.services.monitoring.state",
|
||||
actions: "clouds.services.monitoring.actions",
|
||||
},
|
||||
cdn: {
|
||||
enabled: "clouds.services.cdn.enabled",
|
||||
invalidations: "clouds.services.cdn.invalidations",
|
||||
@@ -890,6 +897,11 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[apiEndpointKeys.clouds.monitoring.metrics, {path: "/clouds/:cloud/services/monitoring/metrics", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.monitoring.metricData, {path: "/clouds/:cloud/services/monitoring/metric-data", method: "POST"}],
|
||||
[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.cdn.enabled,
|
||||
{
|
||||
|
||||
@@ -33,6 +33,11 @@ import type {
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
MetricSeriesQuery,
|
||||
MetricSummary,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolUserAction,
|
||||
LogsInsightsQueryInput,
|
||||
@@ -1477,6 +1482,70 @@ export async function testUserPoolSignIn(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listMetrics(
|
||||
cloud: CloudProvider,
|
||||
query: { namespace?: string; search?: string } = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<MetricSummary[]> {
|
||||
const res = await apiClient.call<MetricSummary[]>(
|
||||
apiEndpointKeys.clouds.monitoring.metrics,
|
||||
requestOptions(cloud, "monitoring", { signal, params: { namespace: query.namespace || undefined, search: query.search || undefined } }),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getMetricSeries(
|
||||
cloud: CloudProvider,
|
||||
query: MetricSeriesQuery,
|
||||
signal?: AbortSignal,
|
||||
): Promise<MetricSeries> {
|
||||
const res = await apiClient.call<MetricSeries, MetricSeriesQuery>(
|
||||
apiEndpointKeys.clouds.monitoring.metricData,
|
||||
requestOptions(cloud, "monitoring", { signal, body: query }),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function describeAlarmHistory(
|
||||
cloud: CloudProvider,
|
||||
alarmName: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AlarmHistoryItem[]> {
|
||||
const res = await apiClient.call<AlarmHistoryItem[]>(
|
||||
apiEndpointKeys.clouds.monitoring.history,
|
||||
requestOptions(cloud, "monitoring", { signal }),
|
||||
{ cloud, id: alarmName },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function setAlarmState(
|
||||
cloud: CloudProvider,
|
||||
alarmName: string,
|
||||
state: AlarmStateValue,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, { state: AlarmStateValue; reason: string }>(
|
||||
apiEndpointKeys.clouds.monitoring.state,
|
||||
requestOptions(cloud, "monitoring", { body: { state, reason } }),
|
||||
{ cloud, id: alarmName },
|
||||
);
|
||||
}
|
||||
|
||||
export async function setAlarmActionsEnabled(
|
||||
cloud: CloudProvider,
|
||||
alarmName: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, { enabled: boolean }>(
|
||||
apiEndpointKeys.clouds.monitoring.actions,
|
||||
requestOptions(cloud, "monitoring", { body: { enabled } }),
|
||||
{ cloud, id: alarmName },
|
||||
);
|
||||
}
|
||||
|
||||
export async function setDistributionEnabled(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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 {CloudWatchAlarmPanel} from './CloudWatchAlarmPanel'
|
||||
import {HttpError} from '@/api/HttpClient'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCloudResource: vi.fn(),
|
||||
describeAlarmHistory: vi.fn(),
|
||||
getMetricSeries: vi.fn(),
|
||||
setAlarmState: vi.fn(),
|
||||
setAlarmActionsEnabled: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
const alarm: CloudResource = {
|
||||
id: 'high-errors', name: 'high-errors', cloud: 'aws', service: 'monitoring', type: 'alarm', region: 'us-east-1', createdAt: null, status: 'ALARM',
|
||||
metadata: {
|
||||
alarmType: 'metric', stateReason: 'Threshold crossed', actionsEnabled: true, alarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], okActions: [],
|
||||
namespace: 'App', metricName: 'Errors', dimensions: [], statistic: 'Sum', threshold: 5, condition: 'Errors Sum > 5 for 1 × 5 min',
|
||||
},
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><CloudWatchAlarmPanel resource={alarm} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getCloudResource.mockResolvedValue(alarm)
|
||||
api.describeAlarmHistory.mockResolvedValue([{timestamp: null, type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
|
||||
api.getMetricSeries.mockResolvedValue({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 7}]})
|
||||
api.setAlarmState.mockResolvedValue(undefined)
|
||||
api.setAlarmActionsEnabled.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
test('shows the state, its metric with the threshold, the targets and the history', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('Threshold crossed')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Threshold 5')).toBeInTheDocument()
|
||||
expect(screen.getByText('arn:aws:sns:us-east-1:000000000000:alerts')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Alarm updated from OK to ALARM')).toBeInTheDocument()
|
||||
expect(api.getMetricSeries).toHaveBeenCalledWith('aws', expect.objectContaining({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 180}), expect.anything())
|
||||
})
|
||||
|
||||
test('sets a state to test the actions, and turns the actions off', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
const form = await screen.findByRole('form', {name: 'Set alarm state'})
|
||||
await user.selectOptions(within(form).getByLabelText('State'), 'OK')
|
||||
await user.type(within(form).getByLabelText('Reason'), 'Testing')
|
||||
await user.click(within(form).getByRole('button', {name: 'Set state'}))
|
||||
expect(api.setAlarmState).toHaveBeenCalledWith('aws', 'high-errors', 'OK', 'Testing')
|
||||
expect(await screen.findByText(/Set to OK/)).toBeInTheDocument()
|
||||
|
||||
await user.click(within(form).getByRole('button', {name: 'Turn actions off'}))
|
||||
expect(api.setAlarmActionsEnabled).toHaveBeenCalledWith('aws', 'high-errors', false)
|
||||
})
|
||||
|
||||
test('says when the runtime does not record alarm history, without an error', async () => {
|
||||
api.describeAlarmHistory.mockRejectedValue(new HttpError(501, 'Not Implemented', {code: 'operation_not_implemented', error: 'Operation is not implemented by the selected runtime'}, '/history'))
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText(/does not record alarm history yet/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import {RefreshCw} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {describeAlarmHistory, getCloudResource, getMetricSeries, setAlarmActionsEnabled, setAlarmState} from '@/api/cloudProxyClient'
|
||||
import {HttpError} from '@/api/HttpClient'
|
||||
import {MetricChart} from '@/components/MetricChart'
|
||||
import {RANGES} from '@/lib/metricChart'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import type {AlarmStateValue, CloudResource, MetricDimension} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
const STATES: AlarmStateValue[] = ['ALARM', 'OK', 'INSUFFICIENT_DATA']
|
||||
|
||||
export function CloudWatchAlarmPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [rangeMinutes, setRangeMinutes] = useState(180)
|
||||
const [nextState, setNextState] = useState<AlarmStateValue>('ALARM')
|
||||
const [reason, setReason] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const alarmKey = ['cloudwatch-alarm', accountId, resource?.id]
|
||||
const historyKey = ['cloudwatch-alarm-history', accountId, resource?.id]
|
||||
|
||||
useEffect(() => {
|
||||
setNotice('')
|
||||
setReason('')
|
||||
}, [resource?.id])
|
||||
|
||||
const alarmQuery = useQuery({
|
||||
queryKey: alarmKey,
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'monitoring', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
const historyQuery = useQuery({
|
||||
queryKey: historyKey,
|
||||
queryFn: ({signal}) => describeAlarmHistory('aws', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
const alarm = alarmQuery.data ?? resource
|
||||
const metadata = alarm?.metadata ?? {}
|
||||
const isMetric = metadata.alarmType === 'metric' && typeof metadata.metricName === 'string'
|
||||
const dimensions = Array.isArray(metadata.dimensions) ? metadata.dimensions as MetricDimension[] : []
|
||||
const seriesQuery = useQuery({
|
||||
queryKey: ['cloudwatch-alarm-series', accountId, resource?.id, rangeMinutes, metadata.metricName, metadata.statistic],
|
||||
queryFn: ({signal}) => getMetricSeries('aws', {
|
||||
namespace: String(metadata.namespace),
|
||||
metricName: String(metadata.metricName),
|
||||
dimensions,
|
||||
statistic: String(metadata.statistic ?? 'Average'),
|
||||
rangeMinutes,
|
||||
}, signal),
|
||||
enabled: Boolean(resource) && runtimeReachable && isMetric,
|
||||
})
|
||||
|
||||
const refresh = () => Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: alarmKey}),
|
||||
queryClient.invalidateQueries({queryKey: historyKey}),
|
||||
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'monitoring']}),
|
||||
])
|
||||
const stateMutation = useMutation({
|
||||
mutationFn: () => setAlarmState('aws', resource?.id ?? '', nextState, reason),
|
||||
onSuccess: () => {
|
||||
setNotice(`Set to ${nextState}. Its actions ran if they are on; CloudWatch puts it back at the next evaluation.`)
|
||||
setReason('')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const actionsMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => setAlarmActionsEnabled('aws', resource?.id ?? '', enabled),
|
||||
onSuccess: (_result, enabled) => {
|
||||
setNotice(enabled ? 'Actions on: the alarm notifies its targets when its state changes.' : 'Actions off: the alarm still changes state but notifies nobody.')
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource || !alarm) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select an alarm</h3><p>Choose an alarm to see its metric, test its actions and read its history.</p></div></section>
|
||||
}
|
||||
|
||||
const state = alarm.status ?? 'INSUFFICIENT_DATA'
|
||||
const actionsEnabled = metadata.actionsEnabled !== false
|
||||
const targets = [...new Set([...list(metadata.alarmActions), ...list(metadata.okActions)])]
|
||||
const disabled = !runtimeReachable || stateMutation.isPending || actionsMutation.isPending
|
||||
// TheCloud's runtime does not implement DescribeAlarmHistory yet; say so rather than fail.
|
||||
const historyUnsupported = isNotImplemented(historyQuery.error)
|
||||
const error = alarmQuery.error ?? (historyUnsupported ? null : historyQuery.error) ?? seriesQuery.error ?? stateMutation.error ?? actionsMutation.error
|
||||
const history = historyQuery.data ?? []
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Alarm ${alarm.name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div>
|
||||
<p className="eyebrow">Alarm</p>
|
||||
<span className="cloudwatch-state-line"><span className={`badge ${state.toLowerCase().replace(/_/g, '-')}`}>{state}</span> <span className="muted">{String(metadata.stateReason ?? '')}</span></span>
|
||||
</div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || alarmQuery.isFetching} onClick={() => { void refresh(); void seriesQuery.refetch() }}><RefreshCw size={14}/>{alarmQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
<dl className="cognito-facts cloudwatch-facts">
|
||||
<dt>{isMetric ? 'Condition' : 'Rule'}</dt><dd className="mono">{String(metadata.condition ?? '—')}</dd>
|
||||
{isMetric && <><dt>Metric</dt><dd className="mono">{String(metadata.namespace)} / {String(metadata.metricName)}{dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</dd></>}
|
||||
<dt>State changed</dt><dd>{formatDateTime(typeof metadata.stateUpdatedAt === 'string' ? metadata.stateUpdatedAt : null) ?? '—'}</dd>
|
||||
<dt>Notifies</dt><dd className="mono">{targets.length ? targets.join(', ') : 'No actions'}</dd>
|
||||
</dl>
|
||||
|
||||
{isMetric && <>
|
||||
<div className="cloudwatch-range" role="group" aria-label="Time range">
|
||||
{RANGES.map((range) => (
|
||||
<button key={range.minutes} type="button" className={`button compact${rangeMinutes === range.minutes ? ' primary' : ''}`} aria-pressed={rangeMinutes === range.minutes} onClick={() => setRangeMinutes(range.minutes)}>{range.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="cloudwatch-chart">
|
||||
<p className="cloudwatch-chart-title">{String(metadata.metricName)} <span className="muted">· {String(metadata.statistic ?? '')}</span></p>
|
||||
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
|
||||
{seriesQuery.data && <MetricChart series={seriesQuery.data} threshold={typeof metadata.threshold === 'number' ? metadata.threshold : null}/>}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Test the alarm</p><span className="muted">Sets the state now, which runs its actions. CloudWatch evaluates it again at the next period.</span></div></div>
|
||||
<form className="dynamic-form" aria-label="Set alarm state" onSubmit={(event) => { event.preventDefault(); setNotice(''); stateMutation.mutate() }}>
|
||||
<label className="dynamic-field"><span>State</span>
|
||||
<select className="input" value={nextState} onChange={(event) => setNextState(event.target.value as AlarmStateValue)} disabled={disabled}>
|
||||
{STATES.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Reason</span><input className="input" value={reason} placeholder="Testing the on-call notification" onChange={(event) => setReason(event.target.value)} disabled={disabled}/></label>
|
||||
<div className="cloudwatch-form-actions">
|
||||
<button className="button primary" type="submit" disabled={disabled}>{stateMutation.isPending ? 'Setting' : 'Set state'}</button>
|
||||
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); actionsMutation.mutate(!actionsEnabled) }}>{actionsEnabled ? 'Turn actions off' : 'Turn actions on'}</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>}
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">History</p>{history.length > 0 && <span className="muted">Newest first.</span>}</div></div>
|
||||
{historyUnsupported && <p className="muted padded">TheCloud's runtime does not record alarm history yet. State changes still notify the alarm's actions.</p>}
|
||||
{historyQuery.isSuccess && history.length === 0 && <p className="muted padded">No history yet.</p>}
|
||||
{history.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>When</th><th>Type</th><th>What happened</th></tr></thead><tbody>{history.map((item, index) => (
|
||||
<tr key={`${item.timestamp}-${index}`}><td>{formatDateTime(item.timestamp) ?? '—'}</td><td><code>{item.type}</code></td><td>{item.summary}</td></tr>
|
||||
))}</tbody></table></div>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function isNotImplemented(error: unknown): boolean {
|
||||
return error instanceof HttpError && typeof error.body === 'object' && error.body !== null
|
||||
&& (error.body as {code?: unknown}).code === 'operation_not_implemented'
|
||||
}
|
||||
|
||||
function list(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {CloudWatchMetricsExplorer} from './CloudWatchMetricsExplorer'
|
||||
|
||||
const api = vi.hoisted(() => ({listMetrics: vi.fn(), getMetricSeries: vi.fn()}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.listMetrics.mockResolvedValue([
|
||||
{namespace: 'App', metricName: 'Errors', dimensions: []},
|
||||
{namespace: 'AWS/Lambda', metricName: 'Invocations', dimensions: [{name: 'FunctionName', value: 'checkout'}]},
|
||||
])
|
||||
api.getMetricSeries.mockResolvedValue({label: 'Invocations', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 12}]})
|
||||
})
|
||||
|
||||
function renderExplorer() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><CloudWatchMetricsExplorer runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
test('filters metrics by namespace and charts the chosen one', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderExplorer()
|
||||
|
||||
expect(await screen.findByText('2 metrics in 2 namespaces. Pick one to chart it.')).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText('Namespace'), 'AWS/Lambda')
|
||||
expect(screen.queryByText('Errors')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Chart Invocations for checkout'}))
|
||||
await user.selectOptions(screen.getByLabelText('Statistic'), 'Sum')
|
||||
expect(await screen.findByRole('img', {name: /Invocations \(Sum\)/})).toBeInTheDocument()
|
||||
expect(api.getMetricSeries).toHaveBeenLastCalledWith('aws', expect.objectContaining({namespace: 'AWS/Lambda', metricName: 'Invocations', statistic: 'Sum', dimensions: [{name: 'FunctionName', value: 'checkout'}]}), expect.anything())
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import {RefreshCw} from 'lucide-react'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {useMemo, useState} from 'react'
|
||||
import {getMetricSeries, listMetrics} from '@/api/cloudProxyClient'
|
||||
import {MetricChart} from '@/components/MetricChart'
|
||||
import {RANGES} from '@/lib/metricChart'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {MetricSummary} from '@/types/resource'
|
||||
|
||||
const STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount']
|
||||
|
||||
export function CloudWatchMetricsExplorer({runtimeReachable}: {runtimeReachable: boolean}) {
|
||||
const accountId = useAccountId()
|
||||
const [namespace, setNamespace] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<MetricSummary | null>(null)
|
||||
const [statistic, setStatistic] = useState('Average')
|
||||
const [rangeMinutes, setRangeMinutes] = useState(180)
|
||||
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['cloudwatch-metrics', accountId],
|
||||
queryFn: ({signal}) => listMetrics('aws', {}, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const seriesQuery = useQuery({
|
||||
queryKey: ['cloudwatch-series', accountId, selected && metricKey(selected), statistic, rangeMinutes],
|
||||
queryFn: ({signal}) => getMetricSeries('aws', {...selected!, statistic, rangeMinutes}, signal),
|
||||
enabled: runtimeReachable && selected !== null,
|
||||
})
|
||||
|
||||
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data])
|
||||
const namespaces = useMemo(() => [...new Set(metrics.map((metric) => metric.namespace))].sort(), [metrics])
|
||||
const term = search.trim().toLowerCase()
|
||||
const shown = metrics.filter((metric) => (!namespace || metric.namespace === namespace)
|
||||
&& (!term || [metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].some((value) => value.toLowerCase().includes(term))))
|
||||
const error = metricsQuery.error ?? seriesQuery.error
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label="Metrics">
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Metrics</p><span className="muted">{metrics.length} {metrics.length === 1 ? 'metric' : 'metrics'} in {namespaces.length} {namespaces.length === 1 ? 'namespace' : 'namespaces'}. Pick one to chart it.</span></div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || metricsQuery.isFetching} onClick={() => { void metricsQuery.refetch(); if (selected) void seriesQuery.refetch() }}><RefreshCw size={14}/>{metricsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
<div className="cloudwatch-filters">
|
||||
<label className="dynamic-field"><span>Namespace</span>
|
||||
<select className="input" value={namespace} onChange={(event) => setNamespace(event.target.value)}>
|
||||
<option value="">All namespaces</option>
|
||||
{namespaces.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Search</span><input className="input" value={search} placeholder="Metric or dimension" onChange={(event) => setSearch(event.target.value)}/></label>
|
||||
</div>
|
||||
|
||||
{selected && <div className="cloudwatch-chart">
|
||||
<div className="cloudwatch-chart-head">
|
||||
<p className="cloudwatch-chart-title">{selected.metricName} <span className="muted">· {selected.namespace}{selected.dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</span></p>
|
||||
<div className="cloudwatch-range" role="group" aria-label="Chart settings">
|
||||
<select className="input" aria-label="Statistic" value={statistic} onChange={(event) => setStatistic(event.target.value)}>
|
||||
{STATISTICS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
{RANGES.map((range) => (
|
||||
<button key={range.minutes} type="button" className={`button compact${rangeMinutes === range.minutes ? ' primary' : ''}`} aria-pressed={rangeMinutes === range.minutes} onClick={() => setRangeMinutes(range.minutes)}>{range.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
|
||||
{seriesQuery.data && <MetricChart series={seriesQuery.data}/>}
|
||||
</div>}
|
||||
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
{metricsQuery.isSuccess && metrics.length === 0 && <div className="empty compact"><h3>No metrics yet</h3><p>Metrics appear when services or your apps publish them, for example with PutMetricData.</p></div>}
|
||||
{shown.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Metric</th><th>Namespace</th><th>Dimensions</th><th/></tr></thead><tbody>{shown.map((metric) => {
|
||||
const isSelected = selected !== null && metricKey(selected) === metricKey(metric)
|
||||
return <tr key={metricKey(metric)} aria-current={isSelected ? 'true' : undefined}>
|
||||
<td className="mono">{metric.metricName}</td>
|
||||
<td className="mono">{metric.namespace}</td>
|
||||
<td className="mono">{metric.dimensions.length ? metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`).join(', ') : '—'}</td>
|
||||
<td className="cognito-row-actions-cell"><button className="button compact" type="button" aria-label={`Chart ${metric.metricName}${metric.dimensions.length ? ` for ${metric.dimensions.map((dimension) => dimension.value).join(', ')}` : ''}`} onClick={() => setSelected(isSelected ? null : metric)}>{isSelected ? 'Hide' : 'Chart'}</button></td>
|
||||
</tr>
|
||||
})}</tbody></table></div>}
|
||||
{metricsQuery.isSuccess && metrics.length > 0 && shown.length === 0 && <p className="muted padded">No metrics match.</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function metricKey(metric: MetricSummary): string {
|
||||
return [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].join('|')
|
||||
}
|
||||
@@ -67,6 +67,8 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
|
||||
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
||||
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||
@@ -987,6 +989,12 @@ export function DynamicResourceView({
|
||||
{service === "cdn" && cloud === "aws" && (
|
||||
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "monitoring" && cloud === "aws" && (
|
||||
<>
|
||||
<CloudWatchAlarmPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
<CloudWatchMetricsExplorer runtimeReachable={canUseRuntime} />
|
||||
</>
|
||||
)}
|
||||
{service === "compute" && (
|
||||
<ComputePanel
|
||||
cloud={cloud}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {fireEvent, render, screen} from '@testing-library/react'
|
||||
import {expect, test} from 'vitest'
|
||||
import {MetricChart} from './MetricChart'
|
||||
|
||||
const series = {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [
|
||||
{timestamp: '2026-10-25T10:00:00Z', value: 3},
|
||||
{timestamp: '2026-10-25T10:01:00Z', value: 7},
|
||||
]}
|
||||
|
||||
test('draws the line, labels the latest value and the threshold, and tabulates the points', () => {
|
||||
const {container} = render(<MetricChart series={series} threshold={5}/>)
|
||||
|
||||
expect(container.querySelector('.metric-chart-line')?.getAttribute('d')).toMatch(/^M[\d.]+,[\d.]+ L[\d.]+,[\d.]+$/)
|
||||
expect(screen.getByText('Threshold 5')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', {name: /Errors \(Sum\), 2 points; latest 7 .*threshold 5/})).toBeInTheDocument()
|
||||
expect(screen.getByText('Data table (2 points)')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.metric-chart-table tbody tr')).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('shows the tooltip from the keyboard, value first', () => {
|
||||
render(<MetricChart series={series}/>)
|
||||
const chart = screen.getByRole('img')
|
||||
|
||||
fireEvent.focus(chart)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/^7Errors · Sum/)
|
||||
fireEvent.keyDown(chart, {key: 'ArrowLeft'})
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/^3/)
|
||||
fireEvent.keyDown(chart, {key: 'Escape'})
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('says when there is no data', () => {
|
||||
render(<MetricChart series={{...series, points: []}}/>)
|
||||
expect(screen.getByText('No data points in this time range.')).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import {useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent} from 'react'
|
||||
import {formatValue, niceTicks, timeTicks} from '@/lib/metricChart'
|
||||
import type {MetricSeries} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
series: MetricSeries
|
||||
/** The alarm threshold, drawn as a dashed reference line. */
|
||||
threshold?: number | null
|
||||
height?: number
|
||||
}
|
||||
|
||||
const MARGIN = {top: 16, right: 56, bottom: 28, left: 48}
|
||||
/** Few points read as a scatter of values, so each gets a marker. */
|
||||
const MARKER_LIMIT = 24
|
||||
|
||||
/**
|
||||
* One metric over time: a 2px line, a solid hairline grid, the latest value labelled at the end,
|
||||
* an optional dashed threshold, a crosshair tooltip that snaps to the nearest point (keyboard too)
|
||||
* and a table view of the same values.
|
||||
*/
|
||||
export function MetricChart({series, threshold = null, height = 220}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [width, setWidth] = useState(640)
|
||||
const [active, setActive] = useState<number | null>(null)
|
||||
const titleId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
// Without ResizeObserver (old browsers, tests) the chart keeps its default width.
|
||||
if (!element || typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(280, Math.round(entry.contentRect.width))))
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const points = series.points
|
||||
const geometry = useMemo(() => {
|
||||
const innerWidth = width - MARGIN.left - MARGIN.right
|
||||
const innerHeight = height - MARGIN.top - MARGIN.bottom
|
||||
const times = points.map((point) => Date.parse(point.timestamp))
|
||||
const values = points.map((point) => point.value)
|
||||
const maxValue = Math.max(...values, threshold ?? -Infinity, 0)
|
||||
const minValue = Math.min(...values, threshold ?? Infinity, 0)
|
||||
const ticks = niceTicks(minValue, maxValue === minValue ? minValue + 1 : maxValue)
|
||||
const yMin = ticks[0]
|
||||
const yMax = ticks[ticks.length - 1]
|
||||
const tMin = times.length ? Math.min(...times) : 0
|
||||
const tMax = times.length ? Math.max(...times) : 1
|
||||
const x = (time: number) => MARGIN.left + (tMax === tMin ? innerWidth / 2 : ((time - tMin) / (tMax - tMin)) * innerWidth)
|
||||
const y = (value: number) => MARGIN.top + innerHeight - ((value - yMin) / (yMax - yMin)) * innerHeight
|
||||
return {innerWidth, innerHeight, times, ticks, x, y, tMin, tMax}
|
||||
}, [points, threshold, width, height])
|
||||
|
||||
if (points.length === 0) {
|
||||
return <div className="metric-chart metric-chart--empty" ref={containerRef}><p className="muted">No data points in this time range.</p></div>
|
||||
}
|
||||
|
||||
const {times, ticks, x, y, tMin, tMax} = geometry
|
||||
const path = points.map((point, index) => `${index ? 'L' : 'M'}${x(times[index]).toFixed(1)},${y(point.value).toFixed(1)}`).join(' ')
|
||||
const last = points.length - 1
|
||||
const spansDays = tMax - tMin > 24 * 3600_000
|
||||
const xTicks = timeTicks(tMin, tMax, Math.max(2, Math.floor(geometry.innerWidth / 120)))
|
||||
const shown = active ?? null
|
||||
|
||||
const nearest = (clientX: number, svg: SVGSVGElement) => {
|
||||
const box = svg.getBoundingClientRect()
|
||||
const px = ((clientX - box.left) / box.width) * width
|
||||
let best = 0
|
||||
for (let index = 1; index < times.length; index++) {
|
||||
if (Math.abs(x(times[index]) - px) < Math.abs(x(times[best]) - px)) best = index
|
||||
}
|
||||
return best
|
||||
}
|
||||
const onPointerMove = (event: PointerEvent<SVGSVGElement>) => setActive(nearest(event.clientX, event.currentTarget))
|
||||
const onKeyDown = (event: KeyboardEvent<SVGSVGElement>) => {
|
||||
if (event.key === 'ArrowLeft') { event.preventDefault(); setActive((index) => Math.max(0, (index ?? last) - 1)) }
|
||||
if (event.key === 'ArrowRight') { event.preventDefault(); setActive((index) => Math.min(last, (index ?? last) + 1)) }
|
||||
if (event.key === 'Home') { event.preventDefault(); setActive(0) }
|
||||
if (event.key === 'End') { event.preventDefault(); setActive(last) }
|
||||
if (event.key === 'Escape') setActive(null)
|
||||
}
|
||||
const summary = `${series.label} (${series.statistic}), ${points.length} points; latest ${formatValue(points[last].value)} at ${formatTime(times[last], spansDays)}${threshold === null ? '' : `; threshold ${formatValue(threshold)}`}`
|
||||
|
||||
return <div className="metric-chart" ref={containerRef}>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={0}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerLeave={() => setActive(null)}
|
||||
onFocus={() => setActive(last)}
|
||||
onBlur={() => setActive(null)}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<title id={titleId}>{summary}</title>
|
||||
{ticks.map((tick) => (
|
||||
<g key={tick}>
|
||||
<line className="metric-chart-grid" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(tick)} y2={y(tick)}/>
|
||||
<text className="metric-chart-axis" x={MARGIN.left - 8} y={y(tick)} dy="0.32em" textAnchor="end">{formatValue(tick)}</text>
|
||||
</g>
|
||||
))}
|
||||
{xTicks.map((tick) => (
|
||||
<text key={tick} className="metric-chart-axis" x={x(tick)} y={height - 8} textAnchor="middle">{formatTime(tick, spansDays)}</text>
|
||||
))}
|
||||
{threshold !== null && <g>
|
||||
<line className="metric-chart-threshold" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(threshold)} y2={y(threshold)}/>
|
||||
<text className="metric-chart-label" x={width - MARGIN.right} y={y(threshold) - 6} textAnchor="end">{`Threshold ${formatValue(threshold)}`}</text>
|
||||
</g>}
|
||||
<path className="metric-chart-line" d={path}/>
|
||||
{points.length <= MARKER_LIMIT && points.map((point, index) => (
|
||||
<circle key={point.timestamp} className="metric-chart-marker" cx={x(times[index])} cy={y(point.value)} r={4}/>
|
||||
))}
|
||||
<text className="metric-chart-label metric-chart-label--strong" x={x(times[last]) + 8} y={y(points[last].value)} dy="0.32em">{formatValue(points[last].value)}</text>
|
||||
{shown !== null && <g pointerEvents="none">
|
||||
<line className="metric-chart-crosshair" x1={x(times[shown])} x2={x(times[shown])} y1={MARGIN.top} y2={height - MARGIN.bottom}/>
|
||||
<circle className="metric-chart-marker metric-chart-marker--active" cx={x(times[shown])} cy={y(points[shown].value)} r={5}/>
|
||||
</g>}
|
||||
</svg>
|
||||
{shown !== null && <div className="metric-chart-tooltip" role="status" style={tooltipPosition(x(times[shown]), width)}>
|
||||
<strong>{formatValue(points[shown].value)}</strong>
|
||||
<span><span className="metric-chart-key" aria-hidden="true"/>{series.label} · {series.statistic}</span>
|
||||
<span className="muted">{formatTime(times[shown], true)}</span>
|
||||
</div>}
|
||||
<details className="metric-chart-table">
|
||||
<summary>Data table ({points.length} points)</summary>
|
||||
<table className="table"><thead><tr><th>Time</th><th>{series.statistic}</th></tr></thead><tbody>{points.slice().reverse().map((point) => (
|
||||
<tr key={point.timestamp}><td>{new Date(point.timestamp).toLocaleString()}</td><td className="mono">{formatValue(point.value)}</td></tr>
|
||||
))}</tbody></table>
|
||||
</details>
|
||||
</div>
|
||||
}
|
||||
|
||||
function tooltipPosition(px: number, width: number) {
|
||||
return px > width * 0.6 ? {right: width - px + 12} : {left: px + 12}
|
||||
}
|
||||
|
||||
function formatTime(time: number, withDate: boolean): string {
|
||||
const date = new Date(time)
|
||||
return withDate
|
||||
? date.toLocaleString(undefined, {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'})
|
||||
: date.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Activity,
|
||||
Boxes,
|
||||
Circle,
|
||||
Clock,
|
||||
@@ -35,6 +36,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 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'
|
||||
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
||||
@@ -93,6 +95,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
userpools: UsersRound,
|
||||
dns: Globe,
|
||||
cdn: Earth,
|
||||
monitoring: Activity,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
configuration: SlidersHorizontal,
|
||||
@@ -139,6 +142,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
userpools: cognitoIcon,
|
||||
dns: route53Icon,
|
||||
cdn: cloudFrontIcon,
|
||||
monitoring: cloudWatchIcon,
|
||||
workflows: stepFunctionsIcon,
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
--status-error: #ff9a9a;
|
||||
|
||||
--accent: #6cc4ff;
|
||||
--chart-line: #2f9ce0;
|
||||
--accent-tint: rgba(108, 196, 255, 0.16);
|
||||
--accent-tint-faint: rgba(108, 196, 255, 0.07);
|
||||
--accent-tint-border: rgba(108, 196, 255, 0.35);
|
||||
@@ -148,6 +149,7 @@
|
||||
--status-error: #bd3f30;
|
||||
|
||||
--accent: var(--coral);
|
||||
--chart-line: #d9573f;
|
||||
--accent-tint: rgba(237, 111, 92, 0.14);
|
||||
--accent-tint-faint: rgba(237, 111, 92, 0.07);
|
||||
--accent-tint-border: rgba(237, 111, 92, 0.3);
|
||||
@@ -244,6 +246,7 @@
|
||||
--status-error: #ff8da1;
|
||||
|
||||
--accent: #4eff96;
|
||||
--chart-line: #2fae67;
|
||||
--accent-tint: rgba(78, 255, 150, 0.14);
|
||||
--accent-tint-faint: rgba(78, 255, 150, 0.07);
|
||||
--accent-tint-border: rgba(78, 255, 150, 0.34);
|
||||
@@ -6538,3 +6541,59 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
|
||||
.cdn-viewer .copy-value { overflow-wrap: anywhere; min-width: 0; }
|
||||
.cdn-viewer small { flex-basis: 100%; }
|
||||
|
||||
/* CloudWatch: alarm states carry a dot in the status colour next to their label. */
|
||||
.badge.ok::before, .badge.alarm::before, .badge.insufficient-data::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.badge.ok::before { background: var(--status-ok); }
|
||||
.badge.alarm::before { background: var(--status-error); }
|
||||
.badge.insufficient-data::before { background: var(--text-3); }
|
||||
.badge.alarm { border: 1px solid var(--status-error); }
|
||||
|
||||
.cloudwatch-state-line { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.cloudwatch-facts { padding: 0 16px 12px; }
|
||||
.cloudwatch-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; padding: 0 16px 12px; }
|
||||
.cloudwatch-range { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 16px 8px; }
|
||||
.cloudwatch-range .input { width: auto; }
|
||||
.cloudwatch-chart { padding: 4px 16px 12px; }
|
||||
.cloudwatch-chart-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.cloudwatch-chart-head .cloudwatch-range { padding: 0; }
|
||||
.cloudwatch-chart-title { margin: 0 0 6px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.cloudwatch-form-actions { display: flex; flex-wrap: wrap; gap: 8px; align-self: end; }
|
||||
.cognito-row-actions-cell { text-align: right; }
|
||||
|
||||
.metric-chart { position: relative; width: 100%; min-width: 0; }
|
||||
.metric-chart svg { display: block; max-width: 100%; height: auto; overflow: visible; }
|
||||
.metric-chart svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
.metric-chart--empty { padding: 24px 0; }
|
||||
.metric-chart-grid { stroke: var(--border-faint); stroke-width: 1; }
|
||||
.metric-chart-axis { fill: var(--text-3); font-size: 11px; }
|
||||
.metric-chart-line { fill: none; stroke: var(--chart-line); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.metric-chart-marker { fill: var(--chart-line); stroke: var(--surface); stroke-width: 2; }
|
||||
.metric-chart-marker--active { stroke-width: 2.5; }
|
||||
.metric-chart-threshold { stroke: var(--text-3); stroke-width: 1.5; stroke-dasharray: 5 4; }
|
||||
.metric-chart-crosshair { stroke: var(--text-3); stroke-width: 1; }
|
||||
.metric-chart-label { fill: var(--text-3); font-size: 11px; }
|
||||
.metric-chart-label--strong { fill: var(--text-bright, var(--text)); font-weight: 600; }
|
||||
.metric-chart-tooltip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--elevated);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metric-chart-tooltip strong { font-size: 14px; color: var(--text-bright, var(--text)); }
|
||||
.metric-chart-key { display: inline-block; width: 12px; height: 2px; margin: 0 6px 3px 0; background: var(--chart-line); vertical-align: middle; }
|
||||
.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; }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {expect, test} from 'vitest'
|
||||
import {formatValue, niceTicks} from './metricChart'
|
||||
|
||||
test('picks about five round ticks that include zero', () => {
|
||||
expect(niceTicks(0, 7)).toEqual([0, 2, 4, 6, 8])
|
||||
expect(niceTicks(0, 100)).toEqual([0, 25, 50, 75, 100])
|
||||
expect(niceTicks(-3, 3)).toEqual([-4, -2, 0, 2, 4])
|
||||
})
|
||||
|
||||
test('formats values compactly from a thousand up', () => {
|
||||
expect([formatValue(7), formatValue(0.25), formatValue(1500), formatValue(2_000_000), formatValue(12.5)]).toEqual(['7', '0.25', '1.5K', '2M', '12.5'])
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Time ranges offered above CloudWatch charts. */
|
||||
export const RANGES = [{label: '1 hour', minutes: 60}, {label: '3 hours', minutes: 180}, {label: '24 hours', minutes: 1440}, {label: '7 days', minutes: 10080}]
|
||||
|
||||
/** About five round ticks covering [min, max], always including zero when the range does. */
|
||||
export function niceTicks(min: number, max: number, count = 5): number[] {
|
||||
const span = max - min
|
||||
const raw = span / (count - 1)
|
||||
const magnitude = 10 ** Math.floor(Math.log10(raw))
|
||||
const step = [1, 2, 2.5, 5, 10].map((factor) => factor * magnitude).find((candidate) => candidate >= raw) ?? 10 * magnitude
|
||||
const start = Math.floor(min / step) * step
|
||||
const end = Math.ceil(max / step) * step
|
||||
const ticks: number[] = []
|
||||
for (let value = start; value <= end + step / 2; value += step) ticks.push(Number(value.toPrecision(12)))
|
||||
return ticks
|
||||
}
|
||||
|
||||
export function timeTicks(start: number, end: number, count: number): number[] {
|
||||
if (end <= start) return [start]
|
||||
const step = (end - start) / count
|
||||
return Array.from({length: count + 1}, (_, index) => start + index * step)
|
||||
}
|
||||
|
||||
export function formatValue(value: number): string {
|
||||
const abs = Math.abs(value)
|
||||
if (abs >= 1e9) return `${trim(value / 1e9)}B`
|
||||
if (abs >= 1e6) return `${trim(value / 1e6)}M`
|
||||
if (abs >= 1e3) return `${trim(value / 1e3)}K`
|
||||
return trim(value)
|
||||
}
|
||||
|
||||
function trim(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(Math.abs(value) < 10 ? 2 : 1).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
@@ -302,6 +302,41 @@ export interface UserPoolSignInTestInput {
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface MetricDimension {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface MetricSummary {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions: MetricDimension[]
|
||||
}
|
||||
|
||||
export interface MetricSeriesQuery {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions?: MetricDimension[]
|
||||
statistic?: string
|
||||
periodSeconds?: number
|
||||
rangeMinutes?: number
|
||||
}
|
||||
|
||||
export interface MetricSeries {
|
||||
label: string
|
||||
statistic: string
|
||||
periodSeconds: number
|
||||
points: Array<{timestamp: string; value: number}>
|
||||
}
|
||||
|
||||
export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA'
|
||||
|
||||
export interface AlarmHistoryItem {
|
||||
timestamp: string | null
|
||||
type: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
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 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'
|
||||
|
||||
@@ -90,6 +91,7 @@ export interface ServiceSchema {
|
||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
||||
}
|
||||
filters: FieldSchema[]
|
||||
columns: TableColumnSchema[]
|
||||
|
||||