Compare commits

..

11 Commits

Author SHA1 Message Date
Zakaria f1574b89df docs: regenerate the service matrix
CI / checks (pull_request) Successful in 26m4s
Adds Cognito and the services the table had drifted behind on (ECS, Fargate, Lightsail, ElastiCache, MSK, Kinesis and others).
2026-10-23 12:12:40 -04:00
Zakaria fe70e88bcc feat(frontend): manage Cognito app clients and users
Adds a users panel under the selected user pool: copyable app client ids, a create-user form that follows the pool's sign-in method, and the user list with delete. Maps the userpools icon key to the Cognito artwork.
2026-10-23 12:12:40 -04:00
Zakaria 4156afccfe feat(api): add Cognito user pools to the cloud explorer
Adds a userpools catalog entry, schema and AwsUserPoolsAdapter, verified
against Floci core:

- list/get/create/delete user pools; create can set email or username
  sign-in, a relaxed password policy for local test users, and a public
  app client (on by default) whose id is surfaced in the pool metadata
- users as the pool's child items: list (paged), get, create and delete;
  passwords are set permanent by default so users can sign in at once
- map Cognito's HTTP-400 error names (UserNotFoundException,
  UsernameExistsException, AliasExistsException, InvalidPasswordException)
- link Floci's cognito-idp service to the new page on the diagnostics page
2026-10-23 12:12:40 -04:00
Zakaria ece36e4a34 Merge pull request 'feat: add a runtime diagnostics page' (#6) from feat/diagnostics-page into main
CI / checks (push) Has been cancelled
Reviewed-on: #6
2026-10-23 15:42:29 +00:00
Zakaria 953ba951db feat: add a runtime diagnostics page
CI / checks (pull_request) Failing after 13m18s
Adds GET /api/clouds/:cloud/diagnostics and a Diagnostics page in the
General nav. The report combines, in one call:

- runtime reachability, latency, version and edition from /_floci/health
- every service the runtime emulates, with the console pages backed by it
- per-console-service probes (the existing status check)
- EC2 instance containers from /_floci/diagnose
- console uptime, sign-in mode and active session count

Each source is optional, so a down runtime still yields a partial report.
2026-10-23 11:29:48 -04:00
Zakaria dd16bcf4cd Merge pull request 'test: bring stale tests and lint in line with the current UI' (#5) from chore/ci-green-checks into main
CI / checks (push) Has been cancelled
Reviewed-on: #5
2026-10-23 15:16:18 +00:00
Zakaria 432d9d4ce1 ci: run lint, type-check, tests and build on Gitea Actions
CI / checks (pull_request) Successful in 28m29s
Runs on pushes to main and on every pull request.
2026-10-23 11:12:51 -04:00
Zakaria b171f745db test: bring stale tests and lint in line with the current UI
- CodeDeploy: query the redesigned form's labels.
- CloudFormation: expect the parameters and capabilities fields the form
  now submits.
- ALB listeners: advance the five-second minimum create state before
  deleting, since row actions are disabled while it is pending.
- Lightsail: cast the fetch mock as a whole so it type-checks.
- TargetGroupPanel: depend on the stable refetch function to satisfy
  react-hooks/exhaustive-deps.
2026-10-23 11:12:51 -04:00
Zakaria f5e70a78d8 Merge pull request 'fix(api): block open redirects, add health check and body limits, run as non-root' (#4) from fix/api-hardening into main
Reviewed-on: #4
2026-10-23 14:59:32 +00:00
Zakaria c5a2fc9105 fix(api): block open redirects, add health check and body limits, run as non-root
- safeReturnTo resolves the target against a placeholder origin and rejects
  backslashes and control characters, so `/\evil.example` and similar
  payloads can no longer redirect off-site after sign-in.
- Add a real unauthenticated /api/health and answer unknown /api/* paths
  with a JSON 404 instead of the SPA shell.
- Limit request bodies: 16 KB for /api/auth/* (read before a session
  exists), 128 MB for resource create/update and uploads, 10 MB elsewhere.
- Run the production image as an unprivileged user and add a Docker
  HEALTHCHECK against /api/health.
2026-10-23 10:43:41 -04:00
Zakaria c9b0677fed Merge pull request 'feat(frontend): add a terminal theme and three-way theme switcher' (#3) from feat/console-themes-and-aws-focus into main
Reviewed-on: #3
2026-10-23 14:41:07 +00:00
39 changed files with 2142 additions and 36 deletions
+46
View File
@@ -0,0 +1,46 @@
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.4.2
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type-check
run: pnpm type-check
- name: API tests
run: pnpm test
- name: Frontend tests
run: pnpm --filter @floci/frontend test
- name: Build
run: pnpm build
+16 -6
View File
@@ -62,14 +62,21 @@ cd packages/api && bun run scripts/service-matrix.ts
|---|---|---|---|---| |---|---|---|---|---|
| Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No | | Compute | Compute | Yes (list, inspect, create, delete) | Yes (list, inspect, create, delete) | No |
| Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) | | Compute | EKS / AKS / GKE | Yes (list, inspect) | Yes (list, inspect) | Yes (list, create, inspect, delete) |
| Compute | Serverless | Yes (list, create, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) | | Compute | Serverless | Yes (list, create, update, inspect, delete) | Runtime gap | Yes (list, create, inspect, delete) |
| Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) | | Compute | Containers / Cloud Run | No | No | Yes (list, create, delete, inspect) |
| Compute | CodeBuild | Yes (list, create, delete, inspect) | No | No | | Compute | ECS | Yes (list, create, delete, inspect) | No | No |
| Compute | Fargate | Yes (list, create, delete, inspect) | No | No |
| Compute | Lightsail | Yes (list, create, inspect, update, delete) | No | No |
| Developer Tools | CodeBuild | Yes (list, create, delete, inspect) | No | No |
| Developer Tools | CodePipeline | Yes (list, create, delete, inspect) | No | No |
| Developer Tools | CodeDeploy | Yes (list, create, delete, inspect) | No | No |
| Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | | Storage | Storage | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) |
| Storage | ECR / Elastic Container Registry | Yes (list, create, delete, inspect) | No | No |
| Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Databases | Database | Yes (list, create, update, delete, inspect) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No | | Databases | DynamoDB / Cosmos DB NoSQL / NoSQL | Yes (list, create, delete, inspect) | Yes (list, create, delete, inspect) | No |
| Databases | ElastiCache | Yes (list, create, delete, inspect) | No | No |
| Networking | Networking | Yes (list) | No | No | | Networking | Networking | Yes (list) | No | No |
| Networking | ELB / Load Balancing | Yes (list, create, delete, inspect) | No | No | | Networking | ELB / Load Balancing | Yes (list, create, update, delete, inspect) | No | No |
| Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No | | Networking | ELB Target Groups / Target Groups | Yes (list, create, delete, inspect) | No | No |
| Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Integration | SQS / Messaging / Pub/Sub | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Integration | API Gateway | Yes (list, create, delete, inspect) | No | No | | Integration | API Gateway | Yes (list, create, delete, inspect) | No | No |
@@ -77,11 +84,14 @@ cd packages/api && bun run scripts/service-matrix.ts
| Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No | | Integration | EventBridge / Events | Yes (list, create, delete, inspect) | No | No |
| Integration | SES Mailbox / Email | Yes (list, inspect) | No | No | | Integration | SES Mailbox / Email | Yes (list, inspect) | No | No |
| Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) | | Integration | Cloud Scheduler | No | No | Yes (list, create, delete, inspect) |
| Integration | CodePipeline | Yes (list, create, delete, inspect) | No | No | | Integration | Step Functions / Workflows | Yes (list, create, update, delete, inspect) | No | No |
| Integration | Step Functions / Workflows | Yes (list, create, delete, inspect) | No | No | | Analytics | MSK / Managed Streaming for Kafka | Yes (legacy page) | No | No |
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, delete, inspect) | No | No | | Analytics | MSK Connect / Managed Streaming for Kafka Connect | Yes (legacy page) | No | No |
| Analytics | Kinesis / Kinesis Data Streams | Yes (list, create, inspect, delete) | No | No |
| Provisioning | CloudFormation / Infrastructure as Code | Yes (list, create, update, delete, inspect) | No | No |
| Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No | | Provisioning | AppConfig / Configuration | Yes (list, create, delete, inspect) | No | No |
| Security | Identity | Yes (list, create, delete, inspect) | No | No | | Security | Identity | Yes (list, create, delete, inspect) | No | No |
| Security | Cognito / User Directories | Yes (list, create, delete, inspect) | No | No |
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | 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 | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No | | Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
+6 -1
View File
@@ -23,7 +23,9 @@ RUN bun build --compile --minify src/index.ts --outfile server
# Stage 3 — minimal runtime (no bun, no node_modules) # Stage 3 — minimal runtime (no bun, no node_modules)
FROM alpine:3 FROM alpine:3
RUN apk add --no-cache ca-certificates libstdc++ RUN apk add --no-cache ca-certificates libstdc++ \
&& addgroup -S -g 10001 thecloud \
&& adduser -S -D -H -u 10001 -G thecloud thecloud
WORKDIR /app WORKDIR /app
COPY --from=api-build /app/server ./server COPY --from=api-build /app/server ./server
COPY --from=frontend-build /app/packages/frontend/dist ./public COPY --from=frontend-build /app/packages/frontend/dist ./public
@@ -33,4 +35,7 @@ COPY svgAsset/authentik.svg ./public/authentik.svg
COPY aws-icons-svg ./public/aws-icons COPY aws-icons-svg ./public/aws-icons
ENV PORT=4500 ENV PORT=4500
EXPOSE 4500 EXPOSE 4500
USER thecloud
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-4500}/api/health" || exit 1
CMD ["./server"] CMD ["./server"]
+1
View File
@@ -18,6 +18,7 @@
"@aws-sdk/client-codebuild": "^3.1076.0", "@aws-sdk/client-codebuild": "^3.1076.0",
"@aws-sdk/client-codedeploy": "^3.1144.0", "@aws-sdk/client-codedeploy": "^3.1144.0",
"@aws-sdk/client-codepipeline": "^3.1076.0", "@aws-sdk/client-codepipeline": "^3.1076.0",
"@aws-sdk/client-cognito-identity-provider": "^3.1146.0",
"@aws-sdk/client-dynamodb": "^3.1088.0", "@aws-sdk/client-dynamodb": "^3.1088.0",
"@aws-sdk/client-ec2": "^3.1076.0", "@aws-sdk/client-ec2": "^3.1076.0",
"@aws-sdk/client-ecr": "^3.1076.0", "@aws-sdk/client-ecr": "^3.1076.0",
@@ -60,9 +60,9 @@ describe('AwsLightsailAdapter', () => {
test('merges diagnostic host-port mappings into UI metadata', async () => { test('merges diagnostic host-port mappings into UI metadata', async () => {
process.env.FLOCI_ENDPOINT = 'http://core.test:4566' process.env.FLOCI_ENDPOINT = 'http://core.test:4566'
const fetchMock = globalThis.fetch const fetchMock = globalThis.fetch
globalThis.fetch = async () => new Response(JSON.stringify({mappings: [ globalThis.fetch = (async () => new Response(JSON.stringify({mappings: [
{instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'}, {instancePort: 80, host: 'localhost', hostPort: 18080, protocol: 'tcp'},
]})) ]}))) as unknown as typeof fetch
try { try {
const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web') const resource = await new AwsLightsailAdapter(stubLightsail().client).get('web')
expect(resource?.metadata.hostPortMappings).toEqual([ expect(resource?.metadata.hostPortMappings).toEqual([
@@ -0,0 +1,257 @@
import {describe, expect, test} from 'bun:test'
import {
AdminCreateUserCommand,
AdminDeleteUserCommand,
AdminGetUserCommand,
AdminSetUserPasswordCommand,
CreateUserPoolClientCommand,
CreateUserPoolCommand,
DeleteUserPoolCommand,
DescribeUserPoolCommand,
ListUserPoolClientsCommand,
ListUserPoolsCommand,
ListUsersCommand,
type CognitoIdentityProviderClient,
} from '@aws-sdk/client-cognito-identity-provider'
import {AwsUserPoolsAdapter} from './AwsUserPoolsAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubCognito(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as CognitoIdentityProviderClient
return {client, sent}
}
function awsError(name: string): Error {
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: 400}})
}
const POOL_ID = 'us-east-1_8e65d229a'
const CREATED = new Date('2026-10-23T16:01:22.000Z')
const pool = {
Id: POOL_ID,
Name: 'orders-users',
Arn: `arn:aws:cognito-idp:us-east-1:000000000000:userpool/${POOL_ID}`,
Status: 'Enabled',
CreationDate: CREATED,
UsernameAttributes: ['email'],
EstimatedNumberOfUsers: 1,
MfaConfiguration: 'OFF',
DeletionProtection: 'INACTIVE',
Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: true, RequireLowercase: true, RequireNumbers: true, RequireSymbols: true}},
}
const alice = {
Username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436',
Attributes: [
{Name: 'sub', Value: 'aa36ecc1-ed8a-43b5-84e9-988398c17436'},
{Name: 'email', Value: 'alice@example.com'},
{Name: 'email_verified', Value: 'true'},
],
UserCreateDate: CREATED,
UserLastModifiedDate: CREATED,
Enabled: true,
UserStatus: 'CONFIRMED',
}
function runtimeStub() {
return stubCognito((command) => {
if (command instanceof CreateUserPoolCommand) return {UserPool: pool}
if (command instanceof DescribeUserPoolCommand) return {UserPool: pool}
if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client', UserPoolId: POOL_ID}]}
if (command instanceof AdminCreateUserCommand) return {User: {...alice, UserStatus: 'FORCE_CHANGE_PASSWORD'}}
if (command instanceof AdminGetUserCommand) return {...alice, UserAttributes: alice.Attributes}
return {}
})
}
describe('AwsUserPoolsAdapter', () => {
test('lists every page of user pools and filters by name or id', async () => {
const {client, sent} = stubCognito((command) => {
if (!(command instanceof ListUserPoolsCommand)) return {}
return command.input.NextToken
? {UserPools: [{Id: 'us-east-1_second', Name: 'billing', Status: 'Enabled', CreationDate: CREATED}]}
: {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}], NextToken: 'page-2'}
})
const adapter = new AwsUserPoolsAdapter(client)
expect((await adapter.list()).map((resource) => resource.id)).toEqual([POOL_ID, 'us-east-1_second'])
expect(sent.filter((command) => command instanceof ListUserPoolsCommand)).toHaveLength(2)
expect((await adapter.list({search: 'BILL'})).map((resource) => resource.name)).toEqual(['billing'])
expect((await adapter.list())[0]).toMatchObject({type: 'user-pool', region: 'us-east-1', status: 'Enabled', createdAt: CREATED.toISOString()})
})
test('lists pools with the details the inspector shows', async () => {
const {client} = stubCognito((command) => {
if (command instanceof ListUserPoolsCommand) return {UserPools: [{Id: POOL_ID, Name: 'orders-users', Status: 'Enabled', CreationDate: CREATED}]}
if (command instanceof DescribeUserPoolCommand) return {UserPool: pool}
if (command instanceof ListUserPoolClientsCommand) return {UserPoolClients: [{ClientId: 'a8e5c7d38d3b4a1a97c9d822de', ClientName: 'orders-users-client'}]}
return {}
})
const [listed] = await new AwsUserPoolsAdapter(client).list()
expect(listed.metadata).toMatchObject({signInWith: 'email', appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]})
})
test('describes a pool with its sign-in method, password policy and app clients', async () => {
const resource = await new AwsUserPoolsAdapter(runtimeStub().client).get(POOL_ID)
expect(resource?.metadata).toEqual({
arn: pool.Arn,
signInWith: 'email',
passwordPolicy: '8+ characters with uppercase, lowercase, number, symbol',
estimatedNumberOfUsers: 1,
mfaConfiguration: 'OFF',
deletionProtection: 'INACTIVE',
appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}],
})
})
test('summarises a relaxed password policy without character rules', async () => {
const {client} = stubCognito((command) => {
if (command instanceof DescribeUserPoolCommand) return {UserPool: {...pool, Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}}
return {}
})
expect((await new AwsUserPoolsAdapter(client).get(POOL_ID))?.metadata.passwordPolicy).toBe('8+ characters')
})
test('returns null for a pool the runtime does not know', async () => {
const {client} = stubCognito(() => { throw awsError('ResourceNotFoundException') })
expect(await new AwsUserPoolsAdapter(client).get('us-east-1_missing')).toBeNull()
})
test('creates an email pool with a public app client by default', async () => {
const {client, sent} = runtimeStub()
await new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})
const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand
expect(createPool.input).toEqual({PoolName: 'orders-users', UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']})
const createClient = sent.find((command) => command instanceof CreateUserPoolClientCommand) as CreateUserPoolClientCommand
expect(createClient.input).toEqual({
UserPoolId: POOL_ID,
ClientName: 'orders-users-client',
GenerateSecret: false,
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
})
})
test('creates a username pool with a relaxed password policy and no app client', async () => {
const {client, sent} = runtimeStub()
await new AwsUserPoolsAdapter(client).create({values: {name: 'testers', signInWith: 'username', passwordPolicy: 'relaxed', createAppClient: 'no'}})
const createPool = sent.find((command) => command instanceof CreateUserPoolCommand) as CreateUserPoolCommand
expect(createPool.input).toEqual({
PoolName: 'testers',
Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}},
})
expect(sent.some((command) => command instanceof CreateUserPoolClientCommand)).toBe(false)
})
test('removes the new pool when its app client cannot be created', async () => {
const {client, sent} = stubCognito((command) => {
if (command instanceof CreateUserPoolCommand) return {UserPool: pool}
if (command instanceof CreateUserPoolClientCommand) throw awsError('InvalidParameterException')
return {}
})
await expect(new AwsUserPoolsAdapter(client).create({values: {name: 'orders-users'}})).rejects.toThrow('InvalidParameterException')
expect((sent.at(-1) as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID})
})
test('rejects invalid create input before calling the runtime', async () => {
const {client, sent} = runtimeStub()
const adapter = new AwsUserPoolsAdapter(client)
await expect(adapter.create({values: {}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {name: 'bad/name'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {name: 'ok', signInWith: 'phone'}})).rejects.toBeInstanceOf(ValidationError)
expect(sent).toHaveLength(0)
})
test('deletes a pool by id', async () => {
const {client, sent} = runtimeStub()
await new AwsUserPoolsAdapter(client).delete(POOL_ID)
expect(sent[0]).toBeInstanceOf(DeleteUserPoolCommand)
expect((sent[0] as DeleteUserPoolCommand).input).toEqual({UserPoolId: POOL_ID})
})
})
describe('AwsUserPoolsAdapter users', () => {
test('lists users as items keyed by username with their email and status', async () => {
const {client, sent} = stubCognito(() => ({Users: [alice], PaginationToken: 'next-page'}))
const page = await new AwsUserPoolsAdapter(client).items.listItems(POOL_ID, {limit: 500, cursor: 'this-page'})
expect((sent[0] as ListUsersCommand).input).toEqual({UserPoolId: POOL_ID, Limit: 60, PaginationToken: 'this-page'})
expect(page.nextCursor).toBe('next-page')
expect(page.items).toEqual([{
id: alice.Username,
collectionId: null,
timestamp: CREATED.toISOString(),
body: {
username: alice.Username,
email: 'alice@example.com',
emailVerified: true,
status: 'CONFIRMED',
enabled: true,
lastModifiedAt: CREATED.toISOString(),
attributes: {sub: alice.Username, email: 'alice@example.com', email_verified: 'true'},
},
metadata: {},
}])
})
test('creates a user whose password is permanent so it can sign in at once', async () => {
const {client, sent} = runtimeStub()
const user = await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {email: 'alice@example.com', password: ' Passw0rd! '})
expect((sent[0] as AdminCreateUserCommand).input).toEqual({
UserPoolId: POOL_ID,
Username: 'alice@example.com',
UserAttributes: [{Name: 'email', Value: 'alice@example.com'}, {Name: 'email_verified', Value: 'true'}],
TemporaryPassword: ' Passw0rd! ',
MessageAction: 'SUPPRESS',
})
expect((sent[1] as AdminSetUserPasswordCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username, Password: ' Passw0rd! ', Permanent: true})
expect(user.body.status).toBe('CONFIRMED')
})
test('leaves the password temporary when asked', async () => {
const {client, sent} = runtimeStub()
await new AwsUserPoolsAdapter(client).items.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: false})
expect((sent[0] as AdminCreateUserCommand).input.Username).toBe('bob')
expect((sent[0] as AdminCreateUserCommand).input.UserAttributes).toEqual([])
expect(sent.some((command) => command instanceof AdminSetUserPasswordCommand)).toBe(false)
})
test('rejects a user without a name, password or valid email', async () => {
const {client, sent} = runtimeStub()
const users = new AwsUserPoolsAdapter(client).items
await expect(users.putItem!(POOL_ID, {password: 'Passw0rd!'})).rejects.toThrow('Provide a username or an email address')
await expect(users.putItem!(POOL_ID, {email: 'alice@example.com'})).rejects.toThrow('password is required')
await expect(users.putItem!(POOL_ID, {email: 'not-an-email', password: 'Passw0rd!'})).rejects.toThrow('email must be a valid email address')
await expect(users.putItem!(POOL_ID, {username: 'bob', password: 'Passw0rd!', permanent: 'yes'})).rejects.toThrow('permanent must be true or false')
expect(sent).toHaveLength(0)
})
test('returns null for a missing user and deletes by username', async () => {
const missing = stubCognito(() => { throw awsError('UserNotFoundException') })
expect(await new AwsUserPoolsAdapter(missing.client).items.getItem!(POOL_ID, 'nobody')).toBeNull()
const {client, sent} = runtimeStub()
await new AwsUserPoolsAdapter(client).items.deleteItem!(POOL_ID, alice.Username)
expect((sent[0] as AdminDeleteUserCommand).input).toEqual({UserPoolId: POOL_ID, Username: alice.Username})
})
})
@@ -0,0 +1,332 @@
import {
AdminCreateUserCommand,
AdminDeleteUserCommand,
AdminGetUserCommand,
AdminSetUserPasswordCommand,
CreateUserPoolClientCommand,
CreateUserPoolCommand,
DeleteUserPoolCommand,
DescribeUserPoolCommand,
ListUserPoolClientsCommand,
ListUserPoolsCommand,
ListUsersCommand,
type AttributeType,
type CognitoIdentityProviderClient,
type PasswordPolicyType,
type UserPoolClientDescription,
type UserPoolDescriptionType,
type UserPoolType,
type UserType,
} from '@aws-sdk/client-cognito-identity-provider'
import type {ChildItem, CollectionPage, ItemStoreAdapter, PageQuery} from '../cloud-spi/childCollections'
import {RuntimeError, ValidationError} from '../cloud-spi/errors'
import {
USER_POOL_NAME_PATTERN,
USER_POOL_PASSWORD_POLICIES,
USER_POOL_SIGN_IN,
awsUserPoolsSchema,
} from '../cloud-spi/userPoolsSchema'
import type {
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/** Cognito caps every list page at 60. */
const COGNITO_PAGE_LIMIT = 60
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/**
* Talks to Cognito user pools — verified against Floci core (community, `latest`).
*
* Notes from probing the runtime:
* - A missing pool is `ResourceNotFoundException` and a missing user is
* `UserNotFoundException`, both with HTTP **400**; `get` matches on the name.
* - With email sign-in the stored `Username` is the generated `sub`, not the
* address, so users are keyed by `Username` and the email is read from the
* attributes. Admin calls accept either form.
* - An omitted password policy defaults to requiring upper, lower, number and
* symbol, matching AWS.
* - `InitiateAuth` with `USER_PASSWORD_AUTH` issues real tokens once a user's
* password is permanent, so users created here can sign in immediately.
*
* Users are the pool's child items. The app client is created with the pool, as
* the console's create flow does, and its id is surfaced in the pool metadata.
*/
export class AwsUserPoolsAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'userpools' as const
readonly items: ItemStoreAdapter
constructor(private readonly cognito: CognitoIdentityProviderClient) {
this.items = new UserStore(cognito)
}
schema(): ServiceSchema {
return awsUserPoolsSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const pools: UserPoolDescriptionType[] = []
let nextToken: string | undefined
do {
const res = await this.cognito.send(new ListUserPoolsCommand({MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
pools.push(...(res.UserPools ?? []))
nextToken = res.NextToken || undefined
} while (nextToken)
// ListUserPools omits the sign-in method, password policy and app clients the
// inspector shows, so each match is described. Pools are few on a local runtime.
const matches = filterBySearch(pools.flatMap(toSummaryResource), query.search)
return Promise.all(matches.map(async (summary) => (await this.get(summary.id)) ?? summary))
}
async get(id: string): Promise<CloudResource | null> {
try {
const [pool, clients] = await Promise.all([
this.cognito.send(new DescribeUserPoolCommand({UserPoolId: id})),
this.listClients(id),
])
return pool.UserPool ? toResource(pool.UserPool, clients) : null
} catch (error) {
if (isMissing(error)) return null
throw error
}
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const name = requiredString(input.values.name, 'name')
if (!new RegExp(USER_POOL_NAME_PATTERN).test(name)) {
throw new ValidationError('name must be up to 128 letters, digits, spaces or + = , . @ _ -')
}
const signInWith = optionalOneOf(input.values.signInWith, USER_POOL_SIGN_IN, 'signInWith') ?? 'email'
const passwordPolicy = optionalOneOf(input.values.passwordPolicy, USER_POOL_PASSWORD_POLICIES, 'passwordPolicy') ?? 'default'
const createAppClient = optionalOneOf(input.values.createAppClient, ['yes', 'no'] as const, 'createAppClient') ?? 'yes'
const created = await this.cognito.send(new CreateUserPoolCommand({
PoolName: name,
...(signInWith === 'email' ? {UsernameAttributes: ['email'], AutoVerifiedAttributes: ['email']} : {}),
...(passwordPolicy === 'relaxed'
? {Policies: {PasswordPolicy: {MinimumLength: 8, RequireUppercase: false, RequireLowercase: false, RequireNumbers: false, RequireSymbols: false}}}
: {}),
}))
const id = created.UserPool?.Id
if (!id) throw new RuntimeError(`Cognito did not return the created user pool ${name}`)
if (createAppClient === 'yes') {
try {
await this.cognito.send(new CreateUserPoolClientCommand({
UserPoolId: id,
ClientName: `${name}-client`,
GenerateSecret: false,
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
}))
} catch (error) {
// Without its client the pool is not what was asked for; do not leave it half-made.
await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id})).catch(() => undefined)
throw error
}
}
const pool = await this.get(id)
if (!pool) throw new RuntimeError(`Cognito did not return the created user pool ${id}`)
return pool
}
async delete(id: string): Promise<void> {
await this.cognito.send(new DeleteUserPoolCommand({UserPoolId: id}))
}
private async listClients(poolId: string): Promise<UserPoolClientDescription[]> {
const clients: UserPoolClientDescription[] = []
let nextToken: string | undefined
do {
const res = await this.cognito.send(new ListUserPoolClientsCommand({UserPoolId: poolId, MaxResults: COGNITO_PAGE_LIMIT, ...(nextToken ? {NextToken: nextToken} : {})}))
clients.push(...(res.UserPoolClients ?? []))
nextToken = res.NextToken || undefined
} while (nextToken)
return clients
}
}
class UserStore implements ItemStoreAdapter {
constructor(private readonly cognito: CognitoIdentityProviderClient) {}
async listItems(poolId: string, page: PageQuery = {}): Promise<CollectionPage<ChildItem>> {
const res = await this.cognito.send(new ListUsersCommand({
UserPoolId: poolId,
Limit: Math.min(page.limit ?? COGNITO_PAGE_LIMIT, COGNITO_PAGE_LIMIT),
...(page.cursor ? {PaginationToken: page.cursor} : {}),
}))
return {items: (res.Users ?? []).flatMap((user) => toUserItem(user.Username, user.Attributes, user)), nextCursor: res.PaginationToken || null}
}
async getItem(poolId: string, username: string): Promise<ChildItem | null> {
try {
const user = await this.cognito.send(new AdminGetUserCommand({UserPoolId: poolId, Username: username}))
return toUserItem(user.Username, user.UserAttributes, user)[0] ?? null
} catch (error) {
if (isMissing(error)) return null
throw error
}
}
/**
* Creates a user that can sign in straight away: the password is set as
* permanent unless `permanent` is false, in which case Cognito will demand a
* new one at first sign-in (`FORCE_CHANGE_PASSWORD`).
*/
async putItem(poolId: string, body: Record<string, unknown>): Promise<ChildItem> {
const email = optionalString(body.email, 'email')
if (email && !EMAIL_PATTERN.test(email)) throw new ValidationError('email must be a valid email address')
const username = optionalString(body.username, 'username') ?? email
if (!username) throw new ValidationError('Provide a username or an email address')
// Not trimmed: leading or trailing spaces are part of a password.
const password = body.password
if (typeof password !== 'string' || !password) throw new ValidationError('password is required')
if (body.permanent !== undefined && typeof body.permanent !== 'boolean') throw new ValidationError('permanent must be true or false')
const created = await this.cognito.send(new AdminCreateUserCommand({
UserPoolId: poolId,
Username: username,
UserAttributes: email ? [{Name: 'email', Value: email}, {Name: 'email_verified', Value: 'true'}] : [],
TemporaryPassword: password,
MessageAction: 'SUPPRESS',
}))
const storedUsername = created.User?.Username
if (!storedUsername) throw new RuntimeError(`Cognito did not return the user created in ${poolId}`)
if (body.permanent !== false) {
await this.cognito.send(new AdminSetUserPasswordCommand({UserPoolId: poolId, Username: storedUsername, Password: password, Permanent: true}))
}
const user = await this.getItem(poolId, storedUsername)
if (!user) throw new RuntimeError(`Cognito did not return the user ${storedUsername} in ${poolId}`)
return user
}
async deleteItem(poolId: string, username: string): Promise<void> {
await this.cognito.send(new AdminDeleteUserCommand({UserPoolId: poolId, Username: username}))
}
}
function toSummaryResource(pool: UserPoolDescriptionType): CloudResource[] {
if (!pool.Id) return []
return [{
id: pool.Id,
name: pool.Name ?? pool.Id,
cloud: 'aws',
service: 'userpools',
type: 'user-pool',
region: regionFromPoolId(pool.Id),
createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null,
status: pool.Status ?? null,
metadata: {},
}]
}
function toResource(pool: UserPoolType, clients: UserPoolClientDescription[]): CloudResource {
const id = pool.Id ?? ''
const policy = pool.Policies?.PasswordPolicy
return {
id,
name: pool.Name ?? id,
cloud: 'aws',
service: 'userpools',
type: 'user-pool',
region: regionFromPoolId(id),
createdAt: pool.CreationDate ? pool.CreationDate.toISOString() : null,
status: pool.Status ?? null,
metadata: {
arn: pool.Arn,
signInWith: pool.UsernameAttributes?.includes('email') ? 'email' : 'username',
passwordPolicy: policy ? describePasswordPolicy(policy) : null,
estimatedNumberOfUsers: pool.EstimatedNumberOfUsers ?? null,
mfaConfiguration: pool.MfaConfiguration ?? null,
deletionProtection: pool.DeletionProtection ?? null,
appClients: clients.map((client) => ({clientId: client.ClientId ?? '', clientName: client.ClientName ?? ''})),
},
}
}
function toUserItem(
username: string | undefined,
attributes: AttributeType[] | undefined,
user: Pick<UserType, 'Enabled' | 'UserStatus' | 'UserCreateDate' | 'UserLastModifiedDate'>,
): ChildItem[] {
if (!username) return []
const attributeMap = Object.fromEntries((attributes ?? []).flatMap((attribute) => attribute.Name ? [[attribute.Name, attribute.Value ?? '']] : []))
return [{
id: username,
collectionId: null,
timestamp: user.UserCreateDate ? user.UserCreateDate.toISOString() : null,
body: {
username,
email: attributeMap.email ?? null,
emailVerified: attributeMap.email_verified === 'true',
status: user.UserStatus ?? null,
enabled: user.Enabled ?? true,
lastModifiedAt: user.UserLastModifiedDate ? user.UserLastModifiedDate.toISOString() : null,
attributes: attributeMap,
},
metadata: {},
}]
}
/** One readable line for the inspector, e.g. "8+ characters with uppercase, number". */
function describePasswordPolicy(policy: PasswordPolicyType): string {
const required = [
policy.RequireUppercase && 'uppercase',
policy.RequireLowercase && 'lowercase',
policy.RequireNumbers && 'number',
policy.RequireSymbols && 'symbol',
].filter((rule): rule is string => Boolean(rule))
const length = `${policy.MinimumLength ?? 8}+ characters`
return required.length ? `${length} with ${required.join(', ')}` : length
}
/** Pool ids are `<region>_<suffix>`. */
function regionFromPoolId(id: string): string | null {
const separator = id.indexOf('_')
return separator > 0 ? id.slice(0, separator) : null
}
function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] {
const normalized = search?.trim().toLowerCase()
if (!normalized) return resources
return resources.filter(
(resource) =>
resource.name.toLowerCase().includes(normalized) || resource.id.toLowerCase().includes(normalized),
)
}
function isMissing(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const name = (error as {name?: string}).name
return name === 'ResourceNotFoundException' || name === 'UserNotFoundException'
}
function requiredString(value: unknown, field: string): string {
const trimmed = optionalString(value, field)
if (!trimmed) throw new ValidationError(`${field} is required`)
return trimmed
}
function optionalString(value: unknown, field: string): string | undefined {
if (value === undefined || value === null || value === '') return undefined
if (typeof value !== 'string') throw new ValidationError(`${field} must be a string`)
return value.trim() || undefined
}
function optionalOneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T | undefined {
const text = optionalString(value, field)
if (text === undefined) return undefined
if (!(allowed as readonly string[]).includes(text)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
return text as T
}
@@ -127,3 +127,22 @@ describe('logs errors carry HTTP 400 and must not fall through to it', () => {
expect(mapped?.status).toBe(409) expect(mapped?.status).toBe(409)
}) })
}) })
/**
* Cognito reports every failure with HTTP 400, so these names must be mapped
* explicitly or a missing user would read as a validation error.
*/
describe('cognito errors carry HTTP 400 and keep their meaning', () => {
const cases: Array<[string, CloudErrorStatus]> = [
['UserNotFoundException', 404],
['UsernameExistsException', 409],
['AliasExistsException', 409],
['InvalidPasswordException', 400],
]
for (const [name, status] of cases) {
test(`${name} maps to ${status}`, () => {
expect(mapAwsSdkError(sdkError(name, {$metadata: {httpStatusCode: 400}}))?.status).toBe(status)
})
}
})
@@ -58,6 +58,8 @@ const CONFLICT_NAMES = new Set([
'ConcurrentModificationException', 'ConcurrentModificationException',
'IncorrectState', 'IncorrectState',
'DeleteConflictException', 'DeleteConflictException',
'UsernameExistsException',
'AliasExistsException',
]) ])
const ACCESS_DENIED_NAMES = new Set([ const ACCESS_DENIED_NAMES = new Set([
@@ -90,6 +92,7 @@ const VALIDATION_NAMES = new Set([
'InvalidBucketName', 'InvalidBucketName',
'InvalidArgumentException', 'InvalidArgumentException',
'InvalidParameterException', 'InvalidParameterException',
'InvalidPasswordException',
]) ])
const NOT_FOUND_NAMES = new Set([ const NOT_FOUND_NAMES = new Set([
@@ -119,6 +122,7 @@ const NOT_FOUND_NAMES = new Set([
'InvalidSubnetID.NotFound', 'InvalidSubnetID.NotFound',
'InvalidGroupId.NotFound', 'InvalidGroupId.NotFound',
'InvalidAMIID.NotFound', 'InvalidAMIID.NotFound',
'UserNotFoundException',
]) ])
const NOT_IMPLEMENTED_NAMES = new Set([ const NOT_IMPLEMENTED_NAMES = new Set([
+21
View File
@@ -106,6 +106,27 @@ describe('login throttling', () => {
}) })
}) })
describe('post-login redirect', () => {
async function redirectAfterLogin(returnTo: string) {
const response = await request(app(localConfig()), '/api/auth/login', '198.51.100.40', {
method: 'POST',
headers: {cookie: 'floci_ui_csrf=csrf-token', 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
body: new URLSearchParams({username: 'admin', password: 'correct-horse-battery', csrf: 'csrf-token', returnTo}),
})
return (await response.json() as {redirectTo: string}).redirectTo
}
test('keeps same-origin paths with their query and fragment', async () => {
expect(await redirectAfterLogin('/cloud-explorer/aws/storage?search=logs#top')).toBe('/cloud-explorer/aws/storage?search=logs#top')
})
test('refuses destinations that a browser would resolve to another origin', async () => {
for (const returnTo of ['//evil.example', '/\\evil.example', '/\\/evil.example', '/\t/evil.example', 'https://evil.example', '/%0d%0a/evil.example'.replace('%0d%0a', '\r\n')]) {
expect(await redirectAfterLogin(returnTo)).toBe('/console/aws')
}
})
})
describe('OIDC authorization', () => { describe('OIDC authorization', () => {
let privateKey: CryptoKey let privateKey: CryptoKey
let jwk: JWK let jwk: JWK
+13 -1
View File
@@ -290,7 +290,19 @@ function loginErrorMessage(code: string | undefined): string | null {
function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' } function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) } function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' } // Browsers treat `\` like `/` and drop tabs/newlines in URLs, so `/\evil.example` would
// otherwise pass a plain prefix check and land on another origin.
function safeReturnTo(value?: string): string {
const fallback = '/console/aws'
if (!value || !value.startsWith('/') || /[\\\u0000-\u001f\u007f]/.test(value)) return fallback
const base = 'http://return-to.invalid'
try {
const url = new URL(value, base)
return url.origin === base ? `${url.pathname}${url.search}${url.hash}` : fallback
} catch {
return fallback
}
}
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[character] ?? character)) } function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[character] ?? character)) }
function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false } function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false }
function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) } function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) }
+3
View File
@@ -30,6 +30,7 @@ import { SESv2Client } from "@aws-sdk/client-sesv2";
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch"; import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
import { ElastiCacheClient } from "@aws-sdk/client-elasticache"; import { ElastiCacheClient } from "@aws-sdk/client-elasticache";
import { LightsailClient } from "@aws-sdk/client-lightsail"; import { LightsailClient } from "@aws-sdk/client-lightsail";
import { CognitoIdentityProviderClient } from "@aws-sdk/client-cognito-identity-provider";
const endpoint = process.env.FLOCI_ENDPOINT; const endpoint = process.env.FLOCI_ENDPOINT;
const region = process.env.AWS_REGION || "us-east-1"; const region = process.env.AWS_REGION || "us-east-1";
@@ -91,6 +92,7 @@ export type AwsClients = {
cloudwatch: CloudWatchClient; cloudwatch: CloudWatchClient;
elasticache: ElastiCacheClient; elasticache: ElastiCacheClient;
lightsail: LightsailClient; lightsail: LightsailClient;
cognito: CognitoIdentityProviderClient;
}; };
export type AwsClientName = keyof AwsClients; export type AwsClientName = keyof AwsClients;
@@ -136,6 +138,7 @@ function buildClients(accountId: string): AwsClients {
cloudwatch: new CloudWatchClient(base), cloudwatch: new CloudWatchClient(base),
elasticache: new ElastiCacheClient(base), elasticache: new ElastiCacheClient(base),
lightsail: new LightsailClient(base), lightsail: new LightsailClient(base),
cognito: new CognitoIdentityProviderClient(base),
}; };
} }
@@ -239,6 +239,13 @@ export const SERVICE_CATALOG = {
// AWS retains its dedicated page while Azure and GCP use Cloud Explorer. // AWS retains its dedicated page while Azure and GCP use Cloud Explorer.
routeByCloud: {aws: '/secretsmanager'}, routeByCloud: {aws: '/secretsmanager'},
}, },
userpools: {
displayName: 'User Directories',
displayNameByCloud: {aws: 'Cognito'},
iconKey: 'userpools',
group: 'Security',
order: 7,
},
iac: { iac: {
displayName: 'Infrastructure as Code', displayName: 'Infrastructure as Code',
displayNameByCloud: {aws: 'CloudFormation'}, displayNameByCloud: {aws: 'CloudFormation'},
+43 -1
View File
@@ -77,6 +77,48 @@ export interface CloudServiceStatus {
errorCode: string | null errorCode: string | null
} }
/** One service as the runtime itself reports it, which can be far more than the console covers. */
export interface EmulatedServiceStatus {
/** The runtime's own service id, e.g. `states` or `elasticloadbalancing`. */
name: string
state: string
/** Console pages backed by this service; empty when the console has none yet. */
consolePages: Array<{service: CloudServiceType; displayName: string; route: string}>
}
/** A container the runtime started to back a resource, as reported by its diagnostics. */
export interface RuntimeContainer {
kind: 'ec2-instance'
resourceId: string
status: string | null
sshHost: string | null
sshPort: number | null
}
export interface RuntimeDiagnostics {
cloud: CloudProvider
checkedAt: string
runtime: {
status: RuntimeReachability
endpoint: string | null
latencyMs: number | null
error: string | null
version: string | null
edition: string | null
}
/** Null when the runtime does not publish a per-service list. */
emulatedServices: EmulatedServiceStatus[] | null
consoleServices: CloudServiceStatus[]
/** Null when the runtime does not publish container diagnostics. */
containers: RuntimeContainer[] | null
console: {
startedAt: string
uptimeSeconds: number
authMode: 'local' | 'oidc'
activeSessions: number
}
}
export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value' export type FieldType = 'text' | 'textarea' | 'password' | 'select' | 'file' | 'key-value'
export interface FieldSchema { export interface FieldSchema {
@@ -188,7 +230,7 @@ export type KnownResourceType =
| 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function'
| 'secret' | 'iam-user' | 'servicebus-namespace' | 'queue' | 'fifo-queue' | 'secret' | 'iam-user' | 'servicebus-namespace' | 'queue' | 'fifo-queue'
| 'topic' | 'event-bus' | 'rest-api' | 'stack' | 'email' | 'sql-server' | 'topic' | 'event-bus' | 'rest-api' | 'stack' | 'email' | 'sql-server'
| 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group' | 'postgres-flexible-server' | 'load-balancer' | 'state-machine' | 'scheduler-job' | 'key' | 'parameter' | 'cloud-run-service' | 'log-group' | 'user-pool'
export interface CloudResource { export interface CloudResource {
id: string id: string
@@ -0,0 +1,83 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
/** Cognito's own limit and character set for user pool names. */
export const USER_POOL_NAME_PATTERN = '^[\\w\\s+=,.@-]{1,128}$'
export const USER_POOL_SIGN_IN = ['email', 'username'] as const
export const USER_POOL_PASSWORD_POLICIES = ['default', 'relaxed'] as const
const userPoolColumns: TableColumnSchema[] = [
{name: 'name', label: 'Name'},
{name: 'id', label: 'Pool ID', format: 'code'},
{name: 'status', label: 'Status', format: 'badge'},
{name: 'createdAt', label: 'Created', format: 'datetime'},
]
const userPoolFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
export function awsUserPoolsSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'userpools',
displayName: 'Amazon Cognito user pools',
fields: [
{
name: 'name',
label: 'Pool name',
type: 'text',
required: true,
validation: {pattern: USER_POOL_NAME_PATTERN, message: 'Use up to 128 letters, digits, spaces or + = , . @ _ -'},
},
{
name: 'signInWith',
label: 'Sign in with',
type: 'select',
required: false,
defaultValue: 'email',
description: 'Email makes the address the sign-in name; username keeps a separate user name.',
options: [{label: 'Email address', value: 'email'}, {label: 'Username', value: 'username'}],
},
{
name: 'passwordPolicy',
label: 'Password policy',
type: 'select',
required: false,
defaultValue: 'default',
description: 'Relaxed only requires 8 characters, which keeps local test users quick to create.',
options: [
{label: 'AWS default (upper, lower, number, symbol)', value: 'default'},
{label: 'Relaxed (8+ characters)', value: 'relaxed'},
],
},
{
name: 'createAppClient',
label: 'App client',
type: 'select',
required: false,
defaultValue: 'yes',
description: 'A public client (no secret) with username/password and refresh-token sign-in, named <pool>-client.',
options: [{label: 'Create an app client', value: 'yes'}, {label: 'No app client', value: 'no'}],
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List user pools', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create user pool', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete user pool', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect user pool', enabled: true, status: 'available', runtimeRequired: true},
],
itemActions: [
{name: 'list', label: 'List users', enabled: true, status: 'available', runtimeRequired: true},
{name: 'get', label: 'Get user', enabled: true, status: 'available', runtimeRequired: true},
{name: 'put', label: 'Create user', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete user', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: userPoolFilters,
columns: userPoolColumns,
}
}
export function userPoolsSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsUserPoolsSchema() : null
}
+2
View File
@@ -47,6 +47,7 @@ import {AwsFargateAdapter} from './adapter-aws/AwsFargateAdapter'
import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter' import {AwsKinesisAdapter} from './adapter-aws/AwsKinesisAdapter'
import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter' import {AwsElastiCacheAdapter} from './adapter-aws/AwsElastiCacheAdapter'
import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter' import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks' import {createEksService} from './services/eks'
@@ -96,6 +97,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsKinesisAdapter(clients.kinesis), new AwsKinesisAdapter(clients.kinesis),
new AwsElastiCacheAdapter(clients.elasticache), new AwsElastiCacheAdapter(clients.elasticache),
new AwsLightsailAdapter(clients.lightsail), new AwsLightsailAdapter(clients.lightsail),
new AwsUserPoolsAdapter(clients.cognito),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(), new AzureDatabaseAdapter(),
+48
View File
@@ -0,0 +1,48 @@
import {describe, expect, test} from 'bun:test'
import server from './index'
describe('API surface hardening', () => {
test('serves an unauthenticated JSON health check', async () => {
const res = await server.fetch(new Request('http://localhost/api/health'))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({status: 'ok'})
})
test('answers unknown API paths with JSON instead of the SPA shell', async () => {
const res = await server.fetch(new Request('http://localhost/api/does-not-exist'))
expect(res.headers.get('content-type')).toContain('application/json')
})
test('rejects an oversized sign-in body before parsing it', async () => {
const res = await server.fetch(new Request('http://localhost/api/auth/login', {
method: 'POST',
headers: {'content-type': 'application/x-www-form-urlencoded', accept: 'application/json'},
body: `username=admin&password=${'x'.repeat(20 * 1024)}`,
}))
expect(res.status).toBe(413)
expect(await res.json()).toEqual({error: 'Request body is too large'})
})
test('keeps the upload ceiling for object uploads', async () => {
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/storage/resources/bucket-1/object?key=big.bin', {
method: 'PUT',
headers: {'content-type': 'application/octet-stream'},
body: new Uint8Array(11 * 1024 * 1024),
}))
expect(res.status).toBe(401)
})
test('applies the default limit to other API routes', async () => {
const res = await server.fetch(new Request('http://localhost/api/clouds/aws/services/messaging/resources/queue-1/messages', {
method: 'POST',
headers: {'content-type': 'application/json'},
body: new Uint8Array(11 * 1024 * 1024),
}))
expect(res.status).toBe(413)
})
})
+22
View File
@@ -1,6 +1,7 @@
import "dotenv/config"; import "dotenv/config";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { bodyLimit } from "hono/body-limit";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import {authConfig, authMiddleware, authRoutes} from './auth'; import {authConfig, authMiddleware, authRoutes} from './auth';
@@ -44,9 +45,19 @@ app.use(
}), }),
); );
app.use("*", logger()); app.use("*", logger());
// Sign-in endpoints read the body before a session exists, so they get a small
// limit; uploads (S3 objects, Lambda ZIPs sent as base64, Lightsail bundles) keep
// Bun's default 128 MB ceiling.
app.use("/api/*", (c, next) =>
bodyLimit({
maxSize: requestBodyLimit(c.req.path),
onError: (c) => c.json({ error: "Request body is too large" }, 413),
})(c, next),
);
app.use("*", authMiddleware(authentication)); app.use("*", authMiddleware(authentication));
app.use("*", authRoutes(authentication)); app.use("*", authRoutes(authentication));
app.get("/api/health", (c) => c.json({ status: "ok" }));
app.route("/api/eks", eks); app.route("/api/eks", eks);
app.route("/api/ec2", ec2); app.route("/api/ec2", ec2);
app.route("/api/secretsmanager", secretsmanager); app.route("/api/secretsmanager", secretsmanager);
@@ -55,6 +66,7 @@ app.route("/api/msk", msk);
app.route("/api/msk-connect", mskConnect); app.route("/api/msk-connect", mskConnect);
app.route("/api/ses", ses); app.route("/api/ses", ses);
app.route("/api/elasticache", elasticache); app.route("/api/elasticache", elasticache);
app.all("/api/*", (c) => c.json({ error: "Not found" }, 404));
// Serve static frontend files when public/ directory is present (production) // Serve static frontend files when public/ directory is present (production)
app.use("*", serveStatic({ root: "./public" })); app.use("*", serveStatic({ root: "./public" }));
@@ -63,6 +75,16 @@ app.get("*", serveStatic({ path: "./public/index.html" }));
const port = Number(process.env.PORT ?? 4501); const port = Number(process.env.PORT ?? 4501);
export default { port, fetch: app.fetch }; export default { port, fetch: app.fetch };
function requestBodyLimit(path: string): number {
if (path.startsWith("/api/auth/")) return 16 * 1024;
if (
/^\/api\/clouds\/[^/]+\/services\/[^/]+\/resources(?:\/[^/]+(?:\/object|\/static-files|\/node-application)?)?$/.test(path)
) {
return 128 * 1024 * 1024;
}
return 10 * 1024 * 1024;
}
function isSensitiveCredentialRoute(path: string): boolean { function isSensitiveCredentialRoute(path: string): boolean {
return ( return (
path.startsWith("/api/secretsmanager") || path.startsWith("/api/secretsmanager") ||
+7
View File
@@ -14,6 +14,7 @@ import {isServiceType} from '../cloud-spi/serviceCatalog'
import {mapAwsSdkError} from '../adapter-aws/awsErrors' import {mapAwsSdkError} from '../adapter-aws/awsErrors'
import {serviceForAccount} from '../cloudProxy' import {serviceForAccount} from '../cloudProxy'
import {CloudProxyService} from '../service/CloudProxyService' import {CloudProxyService} from '../service/CloudProxyService'
import {runtimeDiagnostics} from '../service/runtimeDiagnostics'
import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment' import {deployLightsailNodeApplication, deployLightsailStaticFiles} from '../services/lightsailDeployment'
// Header (and query-param fallback for direct links such as object downloads) // Header (and query-param fallback for direct links such as object downloads)
@@ -64,6 +65,12 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
return c.json(await svc(c).status(cloud, {includeServices})) return c.json(await svc(c).status(cloud, {includeServices}))
}) })
app.get('/:cloud/diagnostics', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return c.json(await runtimeDiagnostics(cloud, svc(c)))
})
app.get('/:cloud/services/:service/status', async (c) => { app.get('/:cloud/services/:service/status', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider const cloud = c.req.param('cloud') as CloudProvider
const serviceType = c.req.param('service') as CloudServiceType const serviceType = c.req.param('service') as CloudServiceType
@@ -0,0 +1,102 @@
import {describe, expect, test} from 'bun:test'
import type {CloudProvider, CloudServiceDescriptor, CloudStatus} from '../cloud-spi/types'
import {runtimeDiagnostics} from './runtimeDiagnostics'
const STARTED_AT = new Date('2026-10-23T08:00:00.000Z')
const NOW = new Date('2026-10-23T10:30:15.000Z')
function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor {
return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1}
}
function proxy(status: Partial<CloudStatus> = {}, descriptors: CloudServiceDescriptor[] = []) {
return {
status: async (cloud: CloudProvider) => ({
cloud, adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: NOW.toISOString(), error: null,
services: [{cloud, service: 'storage', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: NOW.toISOString(), latencyMs: 4, error: null, errorCode: null}],
...status,
}) as CloudStatus,
services: () => descriptors,
}
}
function runtime(responses: Record<string, unknown>): typeof fetch {
return (async (input: string | URL | Request) => {
const path = new URL(String(input instanceof Request ? input.url : input)).pathname
return path in responses ? Response.json(responses[path]) : new Response('not found', {status: 404})
}) as unknown as typeof fetch
}
const fixed = {now: () => NOW, startedAt: STARTED_AT, authMode: 'oidc' as const, activeSessions: () => 2}
describe('runtimeDiagnostics', () => {
test('reports the runtime version, every emulated service and its console pages', async () => {
const report = await runtimeDiagnostics('aws', proxy({}, [
descriptor('storage', 'S3'),
descriptor('loadbalancing', 'Load Balancers'),
descriptor('targetgroups', 'Target Groups'),
descriptor('scheduler', 'Scheduler', 'coming_soon'),
]), {
...fixed,
fetch: runtime({'/_floci/health': {version: '1.9.0', edition: 'compat', services: {s3: 'running', elasticloadbalancing: 'running', route53: 'running', scheduler: 'stopped'}}}),
})
expect(report.runtime).toMatchObject({status: 'reachable', endpoint: 'http://floci:4566', version: '1.9.0', edition: 'compat', error: null})
expect(report.runtime.latencyMs).toBeNumber()
expect(report.emulatedServices).toEqual([
{name: 'elasticloadbalancing', state: 'running', consolePages: [
{service: 'loadbalancing', displayName: 'Load Balancers', route: '/cloud-explorer/aws/loadbalancing'},
{service: 'targetgroups', displayName: 'Target Groups', route: '/cloud-explorer/aws/targetgroups'},
]},
{name: 'route53', state: 'running', consolePages: []},
{name: 's3', state: 'running', consolePages: [{service: 'storage', displayName: 'S3', route: '/cloud-explorer/aws/storage'}]},
{name: 'scheduler', state: 'stopped', consolePages: []},
])
expect(report.consoleServices).toHaveLength(1)
})
test('lists the containers the runtime started for EC2 instances', async () => {
const report = await runtimeDiagnostics('aws', proxy(), {
...fixed,
fetch: runtime({'/_floci/diagnose': {ec2: {instances: [
{instanceId: 'i-0abc', containerStatus: 'running', sshHost: 'localhost', sshPort: 2201},
{instanceId: 'i-0def', containerStatus: 'stopped'},
{containerStatus: 'running'},
]}}}),
})
expect(report.containers).toEqual([
{kind: 'ec2-instance', resourceId: 'i-0abc', status: 'running', sshHost: 'localhost', sshPort: 2201},
{kind: 'ec2-instance', resourceId: 'i-0def', status: 'stopped', sshHost: null, sshPort: null},
])
})
test('still returns a partial report when the runtime is down', async () => {
const report = await runtimeDiagnostics('aws', proxy({runtime: 'unavailable', error: 'Cannot reach Floci core at http://floci:4566', services: []}), {
...fixed,
fetch: (async () => { throw new TypeError('fetch failed') }) as unknown as typeof fetch,
})
expect(report.runtime).toEqual({status: 'unavailable', endpoint: 'http://floci:4566', latencyMs: null, error: 'Cannot reach Floci core at http://floci:4566', version: null, edition: null})
expect(report.emulatedServices).toBeNull()
expect(report.containers).toBeNull()
})
test('does not query Floci core endpoints for other clouds', async () => {
const requested: string[] = []
const report = await runtimeDiagnostics('azure', proxy(), {
...fixed,
fetch: (async (input: string | URL | Request) => { requested.push(String(input)); return Response.json({}) }) as unknown as typeof fetch,
})
expect(requested).toEqual([])
expect(report.emulatedServices).toBeNull()
})
test('describes the console process itself', async () => {
const report = await runtimeDiagnostics('aws', proxy(), {...fixed, fetch: runtime({})})
expect(report.checkedAt).toBe('2026-10-23T10:30:15.000Z')
expect(report.console).toEqual({startedAt: '2026-10-23T08:00:00.000Z', uptimeSeconds: 9015, authMode: 'oidc', activeSessions: 2})
})
})
@@ -0,0 +1,153 @@
import {authStateSizes} from '../auth'
import type {
CloudProvider,
CloudServiceDescriptor,
CloudServiceType,
EmulatedServiceStatus,
RuntimeContainer,
RuntimeDiagnostics,
} from '../cloud-spi/types'
import type {CloudProxyService} from './CloudProxyService'
import {awsEndpoint} from './runtimeProbe'
const DIAGNOSTICS_TIMEOUT_MS = 3_000
const processStartedAt = new Date()
/**
* Floci core service ids mapped to the console services they back. Only the
* runtime's own ids are listed; whether a console page is actually available is
* still decided by the adapter registry through `services()`.
*/
const AWS_CONSOLE_SERVICES: Record<string, CloudServiceType[]> = {
apigateway: ['apigateway'],
appconfig: ['configuration'],
cloudformation: ['iac'],
codebuild: ['codebuild'],
codedeploy: ['codedeploy'],
codepipeline: ['codepipeline'],
'cognito-idp': ['userpools'],
dynamodb: ['nosql'],
ec2: ['compute', 'networking'],
ecr: ['ecr'],
ecs: ['ecs', 'fargate'],
eks: ['k8s'],
elasticache: ['elasticache'],
elasticloadbalancing: ['loadbalancing', 'targetgroups'],
email: ['email'],
events: ['events'],
iam: ['identity'],
kafka: ['msk'],
kafkaconnect: ['msk-connect'],
kinesis: ['kinesis'],
kms: ['kms'],
lambda: ['serverless'],
lightsail: ['lightsail'],
logs: ['logs'],
rds: ['database'],
'rds-data': ['database'],
s3: ['storage'],
secretsmanager: ['secrets'],
sns: ['notifications'],
sqs: ['messaging'],
ssm: ['parameters'],
states: ['workflows'],
}
type FlociHealth = {services?: Record<string, unknown>; version?: unknown; edition?: unknown}
type FlociDiagnose = {ec2?: {instances?: Array<{instanceId?: unknown; containerStatus?: unknown; sshHost?: unknown; sshPort?: unknown}>}}
export type DiagnosticsDependencies = {
fetch?: typeof fetch
now?: () => Date
startedAt?: Date
authMode?: 'local' | 'oidc'
activeSessions?: () => number
}
/**
* Everything the console can learn about a runtime in one call. Each source is
* optional: a runtime that is down, or one that does not publish a service list
* or container diagnostics, still yields a partial report rather than an error.
*/
export async function runtimeDiagnostics(
cloud: CloudProvider,
proxy: Pick<CloudProxyService, 'status' | 'services'>,
dependencies: DiagnosticsDependencies = {},
): Promise<RuntimeDiagnostics> {
const fetcher = dependencies.fetch ?? globalThis.fetch
const now = dependencies.now?.() ?? new Date()
const startedAt = dependencies.startedAt ?? processStartedAt
const flociCore = cloud === 'aws'
const [status, health, diagnose] = await Promise.all([
proxy.status(cloud, {includeServices: true}),
flociCore ? fetchJson<FlociHealth>(fetcher, `${awsEndpoint()}/_floci/health`) : null,
flociCore ? fetchJson<FlociDiagnose>(fetcher, `${awsEndpoint()}/_floci/diagnose`) : null,
])
return {
cloud,
checkedAt: now.toISOString(),
runtime: {
status: status.runtime,
endpoint: status.endpoint,
latencyMs: health?.latencyMs ?? null,
error: status.error,
version: stringOrNull(health?.body.version),
edition: stringOrNull(health?.body.edition),
},
emulatedServices: emulatedServices(health?.body.services, proxy.services(cloud)),
consoleServices: status.services ?? [],
containers: containers(diagnose?.body),
console: {
startedAt: startedAt.toISOString(),
uptimeSeconds: Math.max(0, Math.floor((now.getTime() - startedAt.getTime()) / 1000)),
authMode: dependencies.authMode ?? (process.env.FLOCI_UI_AUTH_MODE === 'oidc' ? 'oidc' : 'local'),
activeSessions: (dependencies.activeSessions ?? (() => authStateSizes().sessions))(),
},
}
}
function emulatedServices(reported: Record<string, unknown> | undefined, descriptors: CloudServiceDescriptor[]): EmulatedServiceStatus[] | null {
if (!reported || typeof reported !== 'object') return null
const available = new Map(descriptors.filter((descriptor) => descriptor.availability === 'available').map((descriptor) => [descriptor.service, descriptor]))
return Object.entries(reported)
.map(([name, state]) => ({
name,
state: typeof state === 'string' ? state : 'unknown',
consolePages: (AWS_CONSOLE_SERVICES[name] ?? [])
.map((service) => available.get(service))
.filter((descriptor): descriptor is CloudServiceDescriptor => Boolean(descriptor))
.map(({service, displayName, route}) => ({service, displayName, route})),
}))
.sort((left, right) => left.name.localeCompare(right.name))
}
function containers(diagnose: FlociDiagnose | undefined): RuntimeContainer[] | null {
const instances = diagnose?.ec2?.instances
if (!Array.isArray(instances)) return null
return instances
.filter((instance) => typeof instance.instanceId === 'string')
.map((instance) => ({
kind: 'ec2-instance' as const,
resourceId: instance.instanceId as string,
status: stringOrNull(instance.containerStatus),
sshHost: stringOrNull(instance.sshHost),
sshPort: typeof instance.sshPort === 'number' ? instance.sshPort : null,
}))
}
async function fetchJson<T>(fetcher: typeof fetch, url: string): Promise<{body: T; latencyMs: number} | null> {
const startedAt = performance.now()
try {
const response = await fetcher(url, {signal: AbortSignal.timeout(DIAGNOSTICS_TIMEOUT_MS)})
if (!response.ok) return null
return {body: await response.json() as T, latencyMs: Math.round(performance.now() - startedAt)}
} catch {
return null
}
}
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' && value ? value : null
}
+5
View File
@@ -4,6 +4,7 @@ import {SecretsManagerPage} from '@/features/secretsmanager/SecretsManagerPage'
import {CloudExplorerPage} from '@/pages/CloudExplorerPage' import {CloudExplorerPage} from '@/pages/CloudExplorerPage'
import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage' import {CloudConsoleHomePage} from '@/pages/CloudConsoleHomePage'
import {DatabaseDataPage} from '@/pages/DatabaseDataPage' import {DatabaseDataPage} from '@/pages/DatabaseDataPage'
import {DiagnosticsPage} from '@/pages/DiagnosticsPage'
import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage' import {DynamoDbItemEditorPage} from '@/pages/DynamoDbItemEditorPage'
import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage' import {WorkflowStudioPage} from '@/pages/WorkflowStudioPage'
@@ -18,6 +19,10 @@ export default function App() {
<Route path="/console/azure" element={<Navigate to="/console/aws" replace/>}/> <Route path="/console/azure" element={<Navigate to="/console/aws" replace/>}/>
<Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/> <Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/>
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/> <Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
<Route path="/diagnostics" element={<Navigate to="/diagnostics/aws" replace/>}/>
<Route path="/diagnostics/azure" element={<Navigate to="/diagnostics/aws" replace/>}/>
<Route path="/diagnostics/gcp" element={<Navigate to="/diagnostics/aws" replace/>}/>
<Route path="/diagnostics/:cloud" element={<DiagnosticsPage/>}/>
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/> <Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
<Route path="/cloud-explorer/azure/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/> <Route path="/cloud-explorer/azure/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
<Route path="/cloud-explorer/gcp/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/> <Route path="/cloud-explorer/gcp/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
+8
View File
@@ -13,6 +13,7 @@ export const apiEndpointKeys = {
list: "clouds.list", list: "clouds.list",
services: "clouds.services.list", services: "clouds.services.list",
status: "clouds.status.get", status: "clouds.status.get",
diagnostics: "clouds.diagnostics.get",
serviceStatus: "clouds.services.status.get", serviceStatus: "clouds.services.status.get",
schema: "clouds.services.schema.get", schema: "clouds.services.schema.get",
dependencies: "clouds.services.dependencies.get", dependencies: "clouds.services.dependencies.get",
@@ -323,6 +324,13 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "GET", method: "GET",
}, },
], ],
[
apiEndpointKeys.clouds.diagnostics,
{
path: "/clouds/:cloud/diagnostics",
method: "GET",
},
],
[ [
apiEndpointKeys.clouds.serviceStatus, apiEndpointKeys.clouds.serviceStatus,
{ {
@@ -6,6 +6,7 @@ import type {
CloudServiceStatus, CloudServiceStatus,
CloudServiceType, CloudServiceType,
CloudStatus, CloudStatus,
RuntimeDiagnostics,
} from "@/types/cloud"; } from "@/types/cloud";
import type { import type {
AppConfigConfigurationProfile, AppConfigConfigurationProfile,
@@ -86,6 +87,18 @@ export async function getCloudStatus(
return res.data; return res.data;
} }
export async function getCloudDiagnostics(
cloud: CloudProvider,
signal?: AbortSignal,
): Promise<RuntimeDiagnostics> {
const res = await apiClient.call<RuntimeDiagnostics>(
apiEndpointKeys.clouds.diagnostics,
requestOptions(cloud, "cloud-proxy", { signal }),
{ cloud },
);
return res.data;
}
export async function getCloudServiceStatus( export async function getCloudServiceStatus(
cloud: CloudProvider, cloud: CloudProvider,
service: CloudServiceType, service: CloudServiceType,
@@ -1,5 +1,6 @@
import {useQuery} from '@tanstack/react-query' import {useQuery} from '@tanstack/react-query'
import { import {
getCloudDiagnostics,
getCloudResource, getCloudResource,
getCloudServiceStatus, getCloudServiceStatus,
getCloudStatus, getCloudStatus,
@@ -20,6 +21,7 @@ export const cloudQueryKeys = {
clouds: ['clouds'] as const, clouds: ['clouds'] as const,
services: (cloud: CloudProvider) => ['cloud-services', cloud] as const, services: (cloud: CloudProvider) => ['cloud-services', cloud] as const,
status: (cloud: CloudProvider) => ['cloud-status', cloud] as const, status: (cloud: CloudProvider) => ['cloud-status', cloud] as const,
diagnostics: (cloud: CloudProvider) => ['cloud-diagnostics', cloud] as const,
serviceStatus: (cloud: CloudProvider, service: CloudServiceType) => serviceStatus: (cloud: CloudProvider, service: CloudServiceType) =>
['cloud-service-status', cloud, service] as const, ['cloud-service-status', cloud, service] as const,
resources: (cloud: CloudProvider, service: CloudServiceType) => resources: (cloud: CloudProvider, service: CloudServiceType) =>
@@ -53,6 +55,15 @@ export function useCloudStatusQuery(cloud: CloudProvider, options: {refetchInter
}) })
} }
/** Full runtime report; polled slowly because it probes every console service. */
export function useCloudDiagnosticsQuery(cloud: CloudProvider) {
return useQuery({
queryKey: cloudQueryKeys.diagnostics(cloud),
queryFn: ({signal}) => getCloudDiagnostics(cloud, signal),
refetchInterval: 30_000,
})
}
/** Health of the single service being viewed, rather than of the whole cloud. */ /** Health of the single service being viewed, rather than of the whole cloud. */
export function useCloudServiceStatusQuery( export function useCloudServiceStatusQuery(
cloud: CloudProvider, cloud: CloudProvider,
@@ -17,6 +17,6 @@ describe('CloudFormationCreatePanel', () => {
fireEvent.change(screen.getByPlaceholderText('orders-stack'), {target: {value: 'orders-stack'}}) fireEvent.change(screen.getByPlaceholderText('orders-stack'), {target: {value: 'orders-stack'}})
fireEvent.change(screen.getByLabelText('Template S3 URL'), {target: {value: 'http://templates.localhost:4566/orders.yaml'}}) fireEvent.change(screen.getByLabelText('Template S3 URL'), {target: {value: 'http://templates.localhost:4566/orders.yaml'}})
fireEvent.click(screen.getByRole('button', {name: 'Create stack'})) fireEvent.click(screen.getByRole('button', {name: 'Create stack'}))
expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml'}) expect(onSubmit).toHaveBeenCalledWith({stackName: 'orders-stack', templateBody: '', templateUrl: 'http://templates.localhost:4566/orders.yaml', parameters: '{}', capabilities: ''})
}) })
}) })
@@ -9,12 +9,12 @@ describe('CodeDeployCreatePanel', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>) render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
await user.type(screen.getByLabelText('Application name'), 'orders') await user.type(screen.getByLabelText(/^Application name/), 'orders')
await user.type(screen.getByLabelText('Deployment group name'), 'production') await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn') await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
await user.click(screen.getByRole('button', {name: 'Create deployment group'})) await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
expect(screen.queryByLabelText('ECS cluster name')).not.toBeInTheDocument() expect(screen.queryByLabelText(/^Cluster name/)).not.toBeInTheDocument()
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'Lambda', serviceRoleArn: 'role-arn', deploymentConfig: 'AllAtOnce', rollbackOnFailure: true}) expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'Lambda', serviceRoleArn: 'role-arn', deploymentConfig: 'AllAtOnce', rollbackOnFailure: true})
}) })
@@ -24,16 +24,16 @@ describe('CodeDeployCreatePanel', () => {
render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>) render(<CodeDeployCreatePanel isSubmitting={false} submitError={null} onCancel={vi.fn()} onSubmit={onSubmit}/>)
await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS') await user.selectOptions(screen.getByLabelText('Compute platform'), 'ECS')
await user.type(screen.getByLabelText('Application name'), 'orders') await user.type(screen.getByLabelText(/^Application name/), 'orders')
await user.type(screen.getByLabelText('Deployment group name'), 'production') await user.type(screen.getByLabelText(/^Deployment group name/), 'production')
await user.type(screen.getByLabelText('CodeDeploy service role ARN'), 'role-arn') await user.type(screen.getByLabelText(/^Service role ARN/), 'role-arn')
await user.type(screen.getByLabelText('ECS cluster name'), 'orders-cluster') await user.type(screen.getByLabelText(/^Cluster name/), 'orders-cluster')
await user.type(screen.getByLabelText('ECS service name'), 'orders-service') await user.type(screen.getByLabelText(/^Service name/), 'orders-service')
await user.type(screen.getByLabelText('Blue target group name'), 'orders-blue') await user.type(screen.getByLabelText(/Current production target group/), 'orders-blue')
await user.type(screen.getByLabelText('Green target group name'), 'orders-green') await user.type(screen.getByLabelText(/Replacement target group/), 'orders-green')
await user.type(screen.getByLabelText('Production listener ARN'), 'listener-arn') await user.type(screen.getByLabelText(/^Production listener ARN/), 'listener-arn')
await user.selectOptions(screen.getByLabelText('Traffic configuration'), 'Linear10PercentEvery3Minutes') await user.selectOptions(screen.getByLabelText('Traffic configuration'), 'Linear10PercentEvery3Minutes')
await user.click(screen.getByLabelText('Roll back automatically on deployment failure')) await user.click(screen.getByLabelText(/Automatic rollback/))
await user.click(screen.getByRole('button', {name: 'Create deployment group'})) await user.click(screen.getByRole('button', {name: 'Create deployment group'}))
expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'ECS', serviceRoleArn: 'role-arn', clusterName: 'orders-cluster', serviceName: 'orders-service', blueTargetGroupName: 'orders-blue', greenTargetGroupName: 'orders-green', productionListenerArn: 'listener-arn', deploymentConfig: 'Linear10PercentEvery3Minutes', rollbackOnFailure: false}) expect(onSubmit).toHaveBeenCalledWith({applicationName: 'orders', deploymentGroupName: 'production', computePlatform: 'ECS', serviceRoleArn: 'role-arn', clusterName: 'orders-cluster', serviceName: 'orders-service', blueTargetGroupName: 'orders-blue', greenTargetGroupName: 'orders-green', productionListenerArn: 'listener-arn', deploymentConfig: 'Linear10PercentEvery3Minutes', rollbackOnFailure: false})
@@ -0,0 +1,89 @@
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 {CognitoUsersPanel} from './CognitoUsersPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
deleteFlatItem: vi.fn(),
getCloudResource: vi.fn(),
listFlatItems: vi.fn(),
putFlatItem: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const summary: CloudResource = {
id: 'us-east-1_8e65d229a', name: 'orders-users', cloud: 'aws', service: 'userpools', type: 'user-pool',
region: 'us-east-1', createdAt: null, metadata: {},
}
function pool(signInWith: 'email' | 'username'): CloudResource {
return {...summary, metadata: {signInWith, appClients: [{clientId: 'a8e5c7d38d3b4a1a97c9d822de', clientName: 'orders-users-client'}]}}
}
const alice = {
id: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', collectionId: null, timestamp: '2026-10-23T16:01:22.000Z', metadata: {},
body: {username: 'aa36ecc1-ed8a-43b5-84e9-988398c17436', email: 'alice@example.com', status: 'CONFIRMED', enabled: true},
}
function renderPanel() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><CognitoUsersPanel resource={summary} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.listFlatItems.mockResolvedValue({items: [alice], nextCursor: null})
api.putFlatItem.mockResolvedValue(alice)
api.deleteFlatItem.mockResolvedValue(undefined)
})
test('shows the app client id and the pool users', async () => {
api.getCloudResource.mockResolvedValue(pool('email'))
renderPanel()
expect(await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')).toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Copy Client ID'})).toBeInTheDocument()
expect(await screen.findByText('alice@example.com')).toBeInTheDocument()
expect(screen.getByText('CONFIRMED')).toBeInTheDocument()
expect(api.getCloudResource).toHaveBeenCalledWith('aws', 'userpools', summary.id, expect.anything())
})
test('creates an email user who can sign in immediately', async () => {
const user = userEvent.setup()
api.getCloudResource.mockResolvedValue(pool('email'))
renderPanel()
await screen.findByText('a8e5c7d38d3b4a1a97c9d822de')
await user.type(screen.getByLabelText('Email'), 'bob@example.com')
await user.type(screen.getByLabelText('Password'), 'Passw0rd!')
await user.click(screen.getByRole('button', {name: 'Create user'}))
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {email: 'bob@example.com', password: 'Passw0rd!', permanent: true})
})
test('creates a username user with a temporary password when asked', async () => {
const user = userEvent.setup()
api.getCloudResource.mockResolvedValue(pool('username'))
renderPanel()
await user.type(await screen.findByLabelText('Username'), 'bob')
await user.type(screen.getByLabelText('Password'), 'Passw0rd!')
await user.click(screen.getByLabelText(/User can sign in now/))
await user.click(screen.getByRole('button', {name: 'Create user'}))
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, {username: 'bob', password: 'Passw0rd!', permanent: false})
})
test('deletes a user by its stored username', async () => {
const user = userEvent.setup()
api.getCloudResource.mockResolvedValue(pool('email'))
renderPanel()
await user.click(await screen.findByRole('button', {name: 'Delete user alice@example.com'}))
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'userpools', summary.id, alice.id)
})
@@ -0,0 +1,114 @@
import {RefreshCw, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {deleteFlatItem, getCloudResource, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
import {CopyValueButton} from '@/components/CopyValueButton'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type AppClient = {clientId: string; clientName: string}
export function CognitoUsersPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [login, setLogin] = useState('')
const [password, setPassword] = useState('')
const [permanent, setPermanent] = useState(true)
const usersKey = ['cognito-users', accountId, resource?.id]
// The table row is a list summary; sign-in method and app clients come from the full pool.
const poolQuery = useQuery({
queryKey: ['cognito-pool', accountId, resource?.id],
queryFn: ({signal}) => getCloudResource('aws', 'userpools', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
const usersQuery = useQuery({
queryKey: usersKey,
queryFn: ({signal}) => listFlatItems('aws', 'userpools', resource?.id ?? '', undefined, signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setLogin('')
setPassword('')
setPermanent(true)
}, [resource?.id])
const signInWithEmail = poolQuery.data?.metadata.signInWith !== 'username'
const createMutation = useMutation({
mutationFn: () => putFlatItem('aws', 'userpools', resource?.id ?? '', {
...(signInWithEmail ? {email: login.trim()} : {username: login.trim()}),
password,
permanent,
}),
onSuccess: () => {
setLogin('')
setPassword('')
return queryClient.invalidateQueries({queryKey: usersKey})
},
})
const deleteMutation = useMutation({
mutationFn: (username: string) => deleteFlatItem('aws', 'userpools', resource?.id ?? '', username),
onSuccess: () => queryClient.invalidateQueries({queryKey: usersKey}),
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a user pool</h3><p>Choose a user pool to manage its app clients and users.</p></div></section>
}
const users = usersQuery.data?.items ?? []
const appClients = appClientsOf(poolQuery.data)
const pending = createMutation.isPending || deleteMutation.isPending
const error = poolQuery.error ?? usersQuery.error ?? createMutation.error ?? deleteMutation.error
const loginLabel = signInWithEmail ? 'Email' : 'Username'
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Users in ${resource.name}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">App clients</p><span className="muted">Use a client ID with the pool ID to sign users in from your app</span></div>
</div>
{poolQuery.isSuccess && appClients.length === 0 && <p className="muted padded">This pool has no app clients.</p>}
{appClients.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Client ID</th></tr></thead><tbody>{appClients.map((client) => (
<tr key={client.clientId}><td>{client.clientName || '—'}</td><td><span className="copy-value mono">{client.clientId}<CopyValueButton value={client.clientId} label="Client ID"/></span></td></tr>
))}</tbody></table></div>}
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Users</p><span className="muted">{users.length} users in {resource.name}</span></div>
<button className="button" type="button" disabled={!runtimeReachable || usersQuery.isFetching} onClick={() => { void Promise.all([usersQuery.refetch(), poolQuery.refetch()]) }}><RefreshCw size={14}/>{usersQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); if (login.trim() && password) createMutation.mutate() }}>
<label className="dynamic-field"><span>{loginLabel}</span><input className="input" type={signInWithEmail ? 'email' : 'text'} value={login} onChange={(event) => setLogin(event.target.value)} autoComplete="off" disabled={pending} required/></label>
<label className="dynamic-field"><span>Password</span><input className="input" type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" disabled={pending} required/></label>
<label className="dynamic-field dynamic-field--span"><span><input type="checkbox" checked={permanent} onChange={(event) => setPermanent(event.target.checked)} disabled={pending}/> User can sign in now</span><small className="muted">When unchecked the password is temporary and must be changed at first sign-in.</small></label>
<div style={{alignSelf: 'end'}}><button className="button primary" type="submit" disabled={!runtimeReachable || !login.trim() || !password || pending}>{createMutation.isPending ? 'Creating' : 'Create user'}</button></div>
</form>
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{usersQuery.isLoading && <div className="muted padded">Loading users</div>}
{usersQuery.isSuccess && users.length === 0 && <div className="empty compact"><h3>No users</h3><p>Create a user to sign in to apps that use this pool.</p></div>}
{users.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>{loginLabel}</th><th>Status</th><th>Enabled</th><th>Created</th><th/></tr></thead><tbody>{users.map((user) => {
const name = stringValue(user.body.email) || stringValue(user.body.username)
return <tr key={user.id}>
<td><span className="copy-value">{name}<CopyValueButton value={name} label={loginLabel}/></span></td>
<td><code>{stringValue(user.body.status) || '—'}</code></td>
<td>{user.body.enabled === false ? 'No' : 'Yes'}</td>
<td>{formatDateTime(user.timestamp) ?? '—'}</td>
<td><button className="icon-btn danger" type="button" title="Delete user" aria-label={`Delete user ${name}`} disabled={!runtimeReachable || pending} onClick={() => deleteMutation.mutate(user.id)}><Trash2 size={14}/></button></td>
</tr>
})}</tbody></table></div>}
</section>
}
function appClientsOf(pool?: CloudResource): AppClient[] {
const clients = pool?.metadata.appClients
if (!Array.isArray(clients)) return []
return clients.filter((client): client is AppClient => typeof client?.clientId === 'string' && client.clientId !== '')
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value : ''
}
@@ -63,6 +63,7 @@ import { EcrImageBrowser } from "@/components/EcrImageBrowser";
import { LoadBalancerPanel } from "@/components/LoadBalancerPanel"; import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel"; import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel"; import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger"; import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel"; import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel"; import { CodePipelinePanel } from "@/components/CodePipelinePanel";
@@ -969,6 +970,9 @@ export function DynamicResourceView({
{service === "loadbalancing" && cloud === "aws" && ( {service === "loadbalancing" && cloud === "aws" && (
<LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <LoadBalancerListenerPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
{service === "userpools" && cloud === "aws" && (
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "compute" && ( {service === "compute" && (
<ComputePanel <ComputePanel
cloud={cloud} cloud={cloud}
+3 -2
View File
@@ -1,5 +1,5 @@
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom' import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react' import {Activity, AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react'
import {useCallback, useEffect, useRef, useState} from 'react' import {useCallback, useEffect, useRef, useState} from 'react'
import theCloud from '../../../../TheCloud.png' import theCloud from '../../../../TheCloud.png'
import {useTheme} from '@/lib/useTheme' import {useTheme} from '@/lib/useTheme'
@@ -184,6 +184,7 @@ export function Layout() {
<div className="nav-section"> <div className="nav-section">
<span className="nav-label">General</span> <span className="nav-label">General</span>
<NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={18} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/> <NavItem to={`/console/${activeCloud}`} icon={<LayoutDashboard size={18} aria-hidden="true"/>} label="Console Home" collapsed={collapsed}/>
<NavItem to={`/diagnostics/${activeCloud}`} icon={<Activity size={18} aria-hidden="true"/>} label="Diagnostics" collapsed={collapsed}/>
</div> </div>
<CloudServiceNav collapsed={collapsed}/> <CloudServiceNav collapsed={collapsed}/>
</nav> </nav>
@@ -356,6 +357,6 @@ function TopbarSearch() {
} }
function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' { function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' {
const match = pathname.match(/^\/(?:cloud-explorer|console)\/(aws|azure|gcp)(?:\/|$)/) const match = pathname.match(/^\/(?:cloud-explorer|console|diagnostics)\/(aws|azure|gcp)(?:\/|$)/)
return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp' return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp'
} }
@@ -1,7 +1,7 @@
import {render, screen} from '@testing-library/react' import {render, screen, waitFor} from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import {QueryClient, QueryClientProvider} from '@tanstack/react-query' import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {expect, test, vi} from 'vitest' import {afterEach, expect, test, vi} from 'vitest'
import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel' import {LoadBalancerListenerPanel} from './LoadBalancerListenerPanel'
const api = vi.hoisted(() => ({ const api = vi.hoisted(() => ({
@@ -14,8 +14,13 @@ const api = vi.hoisted(() => ({
vi.mock('@/api/cloudProxyClient', () => api) vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'})) vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
afterEach(() => {
vi.useRealTimers()
})
test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => { test('creates and deletes selected ALB HTTP listeners through generic cloud proxy items', async () => {
const user = userEvent.setup() vi.useFakeTimers({shouldAdvanceTime: true})
const user = userEvent.setup({advanceTimers: vi.advanceTimersByTime})
api.listCloudResources.mockResolvedValue([{id: 'target-group-arn', name: 'web-targets'}]) api.listCloudResources.mockResolvedValue([{id: 'target-group-arn', name: 'web-targets'}])
api.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null}) api.listFlatItems.mockResolvedValue({items: [{id: 'listener-arn', body: {protocol: 'HTTP', port: 80, targetGroupArn: 'target-group-arn'}}], nextCursor: null})
api.putFlatItem.mockResolvedValue({}) api.putFlatItem.mockResolvedValue({})
@@ -32,6 +37,10 @@ test('creates and deletes selected ALB HTTP listeners through generic cloud prox
await user.click(screen.getByRole('button', {name: /create listener/i})) await user.click(screen.getByRole('button', {name: /create listener/i}))
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', {port: 8080, targetGroupArn: 'target-group-arn'}) expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', {port: 8080, targetGroupArn: 'target-group-arn'})
// Creation holds its pending state for a minimum of five seconds, which disables the row actions.
await vi.advanceTimersByTimeAsync(5_000)
await waitFor(() => expect(screen.getByRole('button', {name: /delete listener 80/i})).toBeEnabled())
await user.click(screen.getByRole('button', {name: /delete listener 80/i})) await user.click(screen.getByRole('button', {name: /delete listener 80/i}))
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn') expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'loadbalancing', 'load-balancer-arn', 'listener-arn')
}) })
@@ -31,6 +31,7 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal), queryFn: ({signal}) => listFlatItems('aws', 'targetgroups', resource?.id ?? '', undefined, signal),
enabled: Boolean(resource) && runtimeReachable, enabled: Boolean(resource) && runtimeReachable,
}) })
const refetchHealth = healthQuery.refetch
useEffect(() => { useEffect(() => {
setInstanceId('') setInstanceId('')
@@ -50,10 +51,10 @@ export function TargetGroupPanel({resource, runtimeReachable}: Props) {
return return
} }
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
void healthQuery.refetch() void refetchHealth()
}, 1_000) }, 1_000)
return () => window.clearInterval(interval) return () => window.clearInterval(interval)
}, [pendingTargets.length, healthQuery.refetch]) }, [pendingTargets.length, refetchHealth])
const refresh = () => { const refresh = () => {
void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()]) void Promise.all([dependenciesQuery.refetch(), healthQuery.refetch()])
@@ -18,6 +18,7 @@ import {
ShieldCheck, ShieldCheck,
SlidersHorizontal, SlidersHorizontal,
Table2, Table2,
UsersRound,
Workflow, Workflow,
Waypoints, Waypoints,
Zap, Zap,
@@ -29,6 +30,7 @@ import appConfigIcon from '@/assets/aws-services/appconfig.svg'
import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.svg' import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.svg'
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg' import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg' import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg' import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg' import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg' import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
@@ -84,6 +86,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
logs: ScrollText, logs: ScrollText,
iam: ShieldCheck, iam: ShieldCheck,
kms: Lock, kms: Lock,
userpools: UsersRound,
loadbalancing: Scale, loadbalancing: Scale,
parameters: SlidersHorizontal, parameters: SlidersHorizontal,
configuration: SlidersHorizontal, configuration: SlidersHorizontal,
@@ -127,6 +130,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
secrets: secretsManagerIcon, secrets: secretsManagerIcon,
serverless: lambdaIcon, serverless: lambdaIcon,
storage: s3Icon, storage: s3Icon,
userpools: cognitoIcon,
workflows: stepFunctionsIcon, workflows: stepFunctionsIcon,
} }
+103
View File
@@ -4261,6 +4261,109 @@ pre,
gap: 14px; gap: 14px;
} }
.cognito-table {
overflow-x: auto;
border-bottom: 1px solid var(--border);
}
.diagnostics {
display: grid;
gap: 14px;
}
.diagnostics .table-panel {
overflow-x: auto;
}
.diagnostics-section-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.diagnostics-section-header h2 {
margin: 0;
color: var(--text-bright);
font-size: 14px;
}
.diagnostics-filters,
.diagnostics-filter-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.diagnostics-filters .input {
width: 180px;
}
.diagnostics-service-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
margin: 0;
padding: 0;
list-style: none;
}
.diagnostics-service {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
min-width: 0;
padding: 9px 16px;
border-bottom: 1px solid var(--border-faint);
font-size: 12px;
}
.diagnostics-page-link {
padding: 1px 7px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--link);
font-size: 11px;
text-decoration: none;
}
.diagnostics-page-link:hover {
border-color: var(--link);
}
.diagnostics-facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
margin: 0;
}
.diagnostics-facts div {
padding: 12px 16px;
}
.diagnostics-facts dt {
color: var(--text-2);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.diagnostics-facts dd {
margin: 4px 0 0;
color: var(--text-bright);
}
@media (max-width: 520px) {
.diagnostics-filters .input {
width: 100%;
}
}
.console-provider-banner { .console-provider-banner {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -0,0 +1,95 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {MemoryRouter, Route, Routes} from 'react-router-dom'
import {expect, test, vi} from 'vitest'
import {DiagnosticsPage} from './DiagnosticsPage'
import type {RuntimeDiagnostics} from '@/types/cloud'
const api = vi.hoisted(() => ({getCloudDiagnostics: vi.fn()}))
vi.mock('@/api/cloudProxyClient', () => api)
const healthy: RuntimeDiagnostics = {
cloud: 'aws',
checkedAt: new Date().toISOString(),
runtime: {status: 'reachable', endpoint: 'http://floci:4566', latencyMs: 12, error: null, version: '1.9.0', edition: 'community'},
emulatedServices: [
{name: 'route53', state: 'running', consolePages: []},
{name: 's3', state: 'running', consolePages: [{service: 'storage', displayName: 'S3', route: '/cloud-explorer/aws/storage'}]},
{name: 'sqs', state: 'stopped', consolePages: [{service: 'messaging', displayName: 'SQS', route: '/cloud-explorer/aws/messaging'}]},
],
consoleServices: [
{cloud: 'aws', service: 'storage', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: '', latencyMs: 4, error: null, errorCode: null},
{cloud: 'aws', service: 'messaging', adapterRegistered: true, runtime: 'unavailable', endpoint: 'http://floci:4566', checkedAt: '', latencyMs: 9, error: 'Service is not running', errorCode: 'RUNTIME_UNAVAILABLE'},
],
containers: [{kind: 'ec2-instance', resourceId: 'i-0abc', status: 'running', sshHost: 'localhost', sshPort: 2201}],
console: {startedAt: '2026-10-23T08:00:00.000Z', uptimeSeconds: 9_015, authMode: 'oidc', activeSessions: 2},
}
function renderPage(path = '/diagnostics/aws') {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={[path]}>
<Routes><Route path="/diagnostics/:cloud" element={<DiagnosticsPage/>}/></Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
test('summarises the runtime and lists failing console services first', async () => {
api.getCloudDiagnostics.mockResolvedValue(healthy)
renderPage()
const summary = await screen.findByRole('region', {name: 'Runtime summary'})
expect(within(summary).getByText('Reachable')).toBeInTheDocument()
expect(screen.getByText('http://floci:4566 · 12 ms')).toBeInTheDocument()
expect(screen.getByText('1.9.0')).toBeInTheDocument()
expect(screen.getByText('1 not running')).toBeInTheDocument()
expect(screen.getByText('2 / 3')).toBeInTheDocument()
const rows = within(screen.getByRole('region', {name: 'Console services'})).getAllByRole('row')
expect(rows[1]).toHaveTextContent('messaging')
expect(rows[1]).toHaveTextContent('Service is not running (RUNTIME_UNAVAILABLE)')
expect(screen.getByRole('link', {name: 'i-0abc'})).toHaveAttribute('href', '/cloud-explorer/aws/compute?resource=i-0abc')
expect(screen.getByText('localhost:2201')).toBeInTheDocument()
expect(screen.getByText('2 h 30 min')).toBeInTheDocument()
expect(screen.getByText('OIDC single sign-on')).toBeInTheDocument()
})
test('filters emulated services by console coverage and state', async () => {
const user = userEvent.setup()
api.getCloudDiagnostics.mockResolvedValue(healthy)
renderPage()
const section = await screen.findByRole('region', {name: 'Emulated services'})
await user.click(within(section).getByRole('button', {name: 'No console page'}))
expect(within(section).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['route53'])
await user.click(within(section).getByRole('button', {name: 'Not running'}))
expect(within(section).getByRole('link', {name: 'SQS'})).toHaveAttribute('href', '/cloud-explorer/aws/messaging')
expect(within(section).getAllByRole('listitem')).toHaveLength(1)
await user.click(within(section).getByRole('button', {name: 'All'}))
await user.type(within(section).getByRole('searchbox'), 's3')
expect(within(section).getAllByRole('listitem').map((item) => item.textContent)).toEqual(['s3S3'])
})
test('shows what is known when the runtime is down', async () => {
api.getCloudDiagnostics.mockResolvedValue({
...healthy,
runtime: {status: 'unavailable', endpoint: 'http://floci:4566', latencyMs: null, error: 'Cannot reach Floci core at http://floci:4566', version: null, edition: null},
emulatedServices: null,
consoleServices: [],
containers: null,
} satisfies RuntimeDiagnostics)
renderPage()
expect(await screen.findByText('Unavailable')).toBeInTheDocument()
expect(screen.getByText('Cannot reach Floci core at http://floci:4566')).toBeInTheDocument()
expect(screen.getAllByText('Not reported by the runtime')).toHaveLength(2)
expect(screen.queryByRole('region', {name: 'Emulated services'})).not.toBeInTheDocument()
expect(screen.getByText('This runtime does not publish container diagnostics.')).toBeInTheDocument()
})
@@ -0,0 +1,221 @@
import {useMemo, useState} from 'react'
import {Activity, RefreshCw} from 'lucide-react'
import {Link, Navigate, useParams} from 'react-router-dom'
import {useCloudDiagnosticsQuery} from '@/api/queries/cloudQueries'
import {formatDateTime, formatRelativeTime} from '@/lib/format'
import type {CloudProvider, CloudServiceStatus, EmulatedServiceStatus, RuntimeDiagnostics} from '@/types/cloud'
type ServiceFilter = 'all' | 'not-running' | 'with-page' | 'without-page'
const SERVICE_FILTERS: Array<{id: ServiceFilter; label: string}> = [
{id: 'all', label: 'All'},
{id: 'not-running', label: 'Not running'},
{id: 'with-page', label: 'Has console page'},
{id: 'without-page', label: 'No console page'},
]
export function DiagnosticsPage() {
const params = useParams()
const cloud = normalizeCloud(params.cloud)
const query = useCloudDiagnosticsQuery(cloud ?? 'aws')
if (!cloud) return <Navigate to="/diagnostics/aws" replace/>
const report = query.data
return (
<>
<div className="page-header cloud-explorer-header">
<div className="page-title">
<Activity size={20}/>
<div>
<h1>Diagnostics</h1>
<p className="muted">{report ? `Checked ${formatRelativeTime(report.checkedAt) ?? 'just now'}` : 'Runtime, services and console health'}</p>
</div>
</div>
<button className="button" type="button" disabled={query.isFetching} onClick={() => void query.refetch()}>
<RefreshCw size={14}/>{query.isFetching ? 'Checking' : 'Re-check'}
</button>
</div>
<div className="content diagnostics">
{query.isLoading && <p className="muted padded">Running diagnostics</p>}
{query.error && <p className="error-text">Could not load diagnostics: {query.error.message}</p>}
{report && <DiagnosticsReport report={report}/>}
</div>
</>
)
}
function DiagnosticsReport({report}: {report: RuntimeDiagnostics}) {
const emulated = report.emulatedServices
const notRunning = emulated?.filter((service) => service.state !== 'running').length ?? 0
const covered = emulated?.filter((service) => service.consolePages.length > 0).length ?? 0
const reachable = report.runtime.status === 'reachable'
return (
<>
<section className="console-summary" aria-label="Runtime summary">
<div className={`summary-tile ${reachable ? 'ready' : 'unavailable'}`}>
<span>Runtime</span>
<strong>{reachable ? 'Reachable' : 'Unavailable'}</strong>
<small>{report.runtime.endpoint ?? 'No endpoint'}{report.runtime.latencyMs !== null ? ` · ${report.runtime.latencyMs} ms` : ''}</small>
</div>
<div className="summary-tile">
<span>Version</span>
<strong>{report.runtime.version ?? 'Unknown'}</strong>
<small>{report.runtime.edition ? `${report.runtime.edition} edition` : 'Not reported by the runtime'}</small>
</div>
<div className={`summary-tile ${notRunning > 0 ? 'pending' : ''}`}>
<span>Emulated services</span>
<strong>{emulated ? emulated.length : '—'}</strong>
<small>{emulated ? (notRunning > 0 ? `${notRunning} not running` : 'All running') : 'Not reported by the runtime'}</small>
</div>
<div className="summary-tile">
<span>Console coverage</span>
<strong>{emulated ? `${covered} / ${emulated.length}` : `${report.consoleServices.length}`}</strong>
<small>{emulated ? 'Emulated services with a console page' : 'Console services'}</small>
</div>
</section>
{report.runtime.error && <p className="error-text">{report.runtime.error}</p>}
<ConsoleServicesTable services={report.consoleServices}/>
{emulated && <EmulatedServices services={emulated}/>}
<ContainersTable report={report}/>
<ConsoleProcess report={report}/>
</>
)
}
function ConsoleServicesTable({services}: {services: CloudServiceStatus[]}) {
const failing = services.filter((service) => service.runtime !== 'reachable')
const ordered = [...failing, ...services.filter((service) => service.runtime === 'reachable')]
return (
<section className="table-panel" aria-labelledby="diagnostics-console-services">
<header className="diagnostics-section-header">
<h2 id="diagnostics-console-services">Console services</h2>
<span className="muted">{failing.length ? `${failing.length} of ${services.length} failing` : `${services.length} responding`}</span>
</header>
{services.length === 0
? <p className="muted padded">No console services are registered for this cloud.</p>
: <table className="table">
<thead><tr><th>Service</th><th>Status</th><th>Latency</th><th>Detail</th></tr></thead>
<tbody>{ordered.map((service) => (
<tr key={service.service}>
<td><code>{service.service}</code></td>
<td><span className={`status ${statusClass(service.runtime)}`}>{statusLabel(service.runtime)}</span></td>
<td>{service.latencyMs === null ? '—' : `${service.latencyMs} ms`}</td>
<td>{service.error ? <span className="error-text compact-text">{service.error}{service.errorCode ? ` (${service.errorCode})` : ''}</span> : <span className="muted">—</span>}</td>
</tr>
))}</tbody>
</table>}
</section>
)
}
function EmulatedServices({services}: {services: EmulatedServiceStatus[]}) {
const [filter, setFilter] = useState<ServiceFilter>('all')
const [search, setSearch] = useState('')
const visible = useMemo(() => {
const term = search.trim().toLowerCase()
return services.filter((service) => {
if (term && !service.name.includes(term) && !service.consolePages.some((page) => page.displayName.toLowerCase().includes(term))) return false
if (filter === 'not-running') return service.state !== 'running'
if (filter === 'with-page') return service.consolePages.length > 0
if (filter === 'without-page') return service.consolePages.length === 0
return true
})
}, [services, filter, search])
return (
<section className="table-panel" aria-labelledby="diagnostics-emulated-services">
<header className="diagnostics-section-header">
<h2 id="diagnostics-emulated-services">Emulated services</h2>
<div className="diagnostics-filters">
<div className="diagnostics-filter-group" role="group" aria-label="Filter emulated services">
{SERVICE_FILTERS.map((option) => (
<button key={option.id} className={`button compact ${filter === option.id ? 'primary' : ''}`} type="button" aria-pressed={filter === option.id} onClick={() => setFilter(option.id)}>{option.label}</button>
))}
</div>
<input className="input" type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Filter services" aria-label="Filter emulated services by name"/>
</div>
</header>
{visible.length === 0
? <p className="muted padded">No emulated services match this filter.</p>
: <ul className="diagnostics-service-grid">{visible.map((service) => (
<li key={service.name} className="diagnostics-service">
<span className={`dot ${service.state === 'running' ? 'healthy' : 'unavailable'}`} aria-hidden="true"/>
<code>{service.name}</code>
{service.state !== 'running' && <span className="muted">{service.state}</span>}
{service.consolePages.map((page) => <Link key={page.service} className="diagnostics-page-link" to={page.route}>{page.displayName}</Link>)}
</li>
))}</ul>}
</section>
)
}
function ContainersTable({report}: {report: RuntimeDiagnostics}) {
const containers = report.containers
return (
<section className="table-panel" aria-labelledby="diagnostics-containers">
<header className="diagnostics-section-header">
<h2 id="diagnostics-containers">Runtime containers</h2>
<span className="muted">Containers started for EC2 instances</span>
</header>
{containers === null && <p className="muted padded">This runtime does not publish container diagnostics.</p>}
{containers?.length === 0 && <p className="muted padded">No EC2 instance containers are running.</p>}
{containers && containers.length > 0 && <table className="table">
<thead><tr><th>Instance</th><th>Container</th><th>SSH</th></tr></thead>
<tbody>{containers.map((container) => (
<tr key={container.resourceId}>
<td><Link to={`/cloud-explorer/${report.cloud}/compute?resource=${encodeURIComponent(container.resourceId)}`}><code>{container.resourceId}</code></Link></td>
<td>{container.status ?? '—'}</td>
<td>{container.sshPort ? <code>{container.sshHost ?? 'localhost'}:{container.sshPort}</code> : '—'}</td>
</tr>
))}</tbody>
</table>}
</section>
)
}
function ConsoleProcess({report}: {report: RuntimeDiagnostics}) {
return (
<section className="table-panel" aria-labelledby="diagnostics-console">
<header className="diagnostics-section-header">
<h2 id="diagnostics-console">Console</h2>
</header>
<dl className="diagnostics-facts">
<div><dt>Uptime</dt><dd>{formatUptime(report.console.uptimeSeconds)}</dd></div>
<div><dt>Started</dt><dd>{formatDateTime(report.console.startedAt) ?? report.console.startedAt}</dd></div>
<div><dt>Sign-in</dt><dd>{report.console.authMode === 'oidc' ? 'OIDC single sign-on' : 'Local administrator'}</dd></div>
<div><dt>Active sessions</dt><dd>{report.console.activeSessions}</dd></div>
</dl>
</section>
)
}
function formatUptime(totalSeconds: number): string {
const days = Math.floor(totalSeconds / 86_400)
const hours = Math.floor((totalSeconds % 86_400) / 3_600)
const minutes = Math.floor((totalSeconds % 3_600) / 60)
if (days > 0) return `${days} d ${hours} h`
if (hours > 0) return `${hours} h ${minutes} min`
if (minutes > 0) return `${minutes} min`
return `${totalSeconds} s`
}
function statusClass(runtime: CloudServiceStatus['runtime']): string {
if (runtime === 'reachable') return 'healthy'
if (runtime === 'coming_soon') return 'degraded'
return 'unavailable'
}
function statusLabel(runtime: CloudServiceStatus['runtime']): string {
if (runtime === 'reachable') return 'Reachable'
if (runtime === 'coming_soon') return 'Not registered'
return 'Unavailable'
}
function normalizeCloud(value?: string): CloudProvider | null {
return value === 'aws' || value === 'azure' || value === 'gcp' ? value : null
}
+36
View File
@@ -83,3 +83,39 @@ export interface CloudServiceStatus {
/** Mapped error code, so "not implemented" reads differently from "down". */ /** Mapped error code, so "not implemented" reads differently from "down". */
errorCode: string | null errorCode: string | null
} }
export interface EmulatedServiceStatus {
name: string
state: string
consolePages: Array<{service: CloudServiceType; displayName: string; route: string}>
}
export interface RuntimeContainer {
kind: 'ec2-instance'
resourceId: string
status: string | null
sshHost: string | null
sshPort: number | null
}
export interface RuntimeDiagnostics {
cloud: CloudProvider
checkedAt: string
runtime: {
status: RuntimeReachability
endpoint: string | null
latencyMs: number | null
error: string | null
version: string | null
edition: string | null
}
emulatedServices: EmulatedServiceStatus[] | null
consoleServices: CloudServiceStatus[]
containers: RuntimeContainer[] | null
console: {
startedAt: string
uptimeSeconds: number
authMode: 'local' | 'oidc'
activeSessions: number
}
}
+220 -4
View File
@@ -34,6 +34,9 @@ importers:
'@aws-sdk/client-codepipeline': '@aws-sdk/client-codepipeline':
specifier: ^3.1076.0 specifier: ^3.1076.0
version: 3.1144.0 version: 3.1144.0
'@aws-sdk/client-cognito-identity-provider':
specifier: ^3.1146.0
version: 3.1156.0
'@aws-sdk/client-dynamodb': '@aws-sdk/client-dynamodb':
specifier: ^3.1088.0 specifier: ^3.1088.0
version: 3.1112.0 version: 3.1112.0
@@ -318,6 +321,10 @@ packages:
resolution: {integrity: sha512-IhmU0tlv959L/1DjetFai1JcYfm1TrR+ZshokjrYe1zYk5Vt51nv07upMNywYH9BcArEPAv6pHUZXYFQITVEIA==} resolution: {integrity: sha512-IhmU0tlv959L/1DjetFai1JcYfm1TrR+ZshokjrYe1zYk5Vt51nv07upMNywYH9BcArEPAv6pHUZXYFQITVEIA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/client-cognito-identity-provider@3.1156.0':
resolution: {integrity: sha512-R+fVyawDRELzLTMKYIrzGtqku6OULvRnk1SQDR1QQhyVnvZXmJDNuKlcvChl0pEFllpO69Rce9TiZwzMAe/2oQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-dynamodb@3.1112.0': '@aws-sdk/client-dynamodb@3.1112.0':
resolution: {integrity: sha512-Dborj6oKxgV9htN0jpdqGA9mTVOAPtYrrNI9ro67JNojtqIp1qHkdTkz1RkVi5Um2meV0qKVvhxLXRsI/S9RYQ==} resolution: {integrity: sha512-Dborj6oKxgV9htN0jpdqGA9mTVOAPtYrrNI9ro67JNojtqIp1qHkdTkz1RkVi5Um2meV0qKVvhxLXRsI/S9RYQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -441,6 +448,10 @@ packages:
resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==} resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.978.5':
resolution: {integrity: sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.54': '@aws-sdk/credential-provider-env@3.972.54':
resolution: {integrity: sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A==} resolution: {integrity: sha512-F4WQCG8GULIt+XrMHsqUM9dZc0eTwZM3HUWByOjIKOwBqJSzUxX8CFAtbgMvWfCua52FS1oi5FXarH3+khFq8A==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -465,6 +476,10 @@ packages:
resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==} resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.76':
resolution: {integrity: sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.56': '@aws-sdk/credential-provider-http@3.972.56':
resolution: {integrity: sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw==} resolution: {integrity: sha512-PiwHgEK2srdfi/lFveyQ+3w/qikyh6MKvuZAdlMC+dwQi4K5iVBr32oh7cugnYw+jA8iGtnQMhCGvLm/mqplmw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -489,6 +504,10 @@ packages:
resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==} resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.78':
resolution: {integrity: sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.972.61': '@aws-sdk/credential-provider-ini@3.972.61':
resolution: {integrity: sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw==} resolution: {integrity: sha512-9CZWMjhBlgfKlqJk40R7kvMOLEIoOr3HJ1J/ELjAH9H5LzR4bCxLujPVM/1PKAEjzSZKZCxWOalm7JUGZbhQqw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -509,6 +528,10 @@ packages:
resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==} resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.21':
resolution: {integrity: sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.7': '@aws-sdk/credential-provider-ini@3.973.7':
resolution: {integrity: sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug==} resolution: {integrity: sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -537,6 +560,10 @@ packages:
resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==} resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.83':
resolution: {integrity: sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.63': '@aws-sdk/credential-provider-node@3.972.63':
resolution: {integrity: sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==} resolution: {integrity: sha512-YmgWtTPZDStyT74ApSHpApD3r7W9znsc+WEZjW0vceom+NAxRx9/F3TyukOKix8kJkPaa49aIREQJdpGeLDiEw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -565,6 +592,10 @@ packages:
resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==} resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.88':
resolution: {integrity: sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.54': '@aws-sdk/credential-provider-process@3.972.54':
resolution: {integrity: sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg==} resolution: {integrity: sha512-UNmUjtTnp3wH3YTZcctN7yK17S2AdaJpiNIdIf0l70hHOdGuDfdMxk62P5rt64GwGm6qkFOYG0js/cU6cdZDdg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -589,6 +620,10 @@ packages:
resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==} resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.76':
resolution: {integrity: sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.972.60': '@aws-sdk/credential-provider-sso@3.972.60':
resolution: {integrity: sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag==} resolution: {integrity: sha512-zH8SvJkTRw1Kb7GARjGPjzkQPfL3jDi/OxK32I5SQq7bgwgFHJZaoDEwkEvFlfNfpByM6n4Rs20Y1mLyOdb6ag==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -609,6 +644,10 @@ packages:
resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==} resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.20':
resolution: {integrity: sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.6': '@aws-sdk/credential-provider-sso@3.973.6':
resolution: {integrity: sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ==} resolution: {integrity: sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -637,6 +676,10 @@ packages:
resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==} resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.82':
resolution: {integrity: sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/dynamodb-codec@3.973.43': '@aws-sdk/dynamodb-codec@3.973.43':
resolution: {integrity: sha512-5nw01fhFJEKM68n0R65S1DijiSAQWZVbvH7IxTIJpFFbggg816jBVYvhK7W+XISjvtdg0rMkF/gQmuLGH713mg==} resolution: {integrity: sha512-5nw01fhFJEKM68n0R65S1DijiSAQWZVbvH7IxTIJpFFbggg816jBVYvhK7W+XISjvtdg0rMkF/gQmuLGH713mg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -697,6 +740,10 @@ packages:
resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==} resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.50':
resolution: {integrity: sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.38': '@aws-sdk/signature-v4-multi-region@3.996.38':
resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -709,6 +756,10 @@ packages:
resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==} resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.49':
resolution: {integrity: sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1080.0': '@aws-sdk/token-providers@3.1080.0':
resolution: {integrity: sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw==} resolution: {integrity: sha512-8PufAQvncWXvdZUvODbuyXa8l3aszefEzwSMBUcgheNbZOmJMcNn388Ebt/piVrUHyxKN1MlxnR2OonzTyZaGw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -733,6 +784,10 @@ packages:
resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==} resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1155.0':
resolution: {integrity: sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.973.15': '@aws-sdk/types@3.973.15':
resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==} resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -753,6 +808,10 @@ packages:
resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==} resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.975.0':
resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/util-dynamodb@3.996.9': '@aws-sdk/util-dynamodb@3.996.9':
resolution: {integrity: sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==} resolution: {integrity: sha512-16x2tRvl7OYpZ0W/DdFJieFriD13+RvuRBDbe5sj/tCEfK86HSGd7I2s5j0ivz8p6KWGkS+5wKRO9OliJkjUOQ==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -1220,6 +1279,10 @@ packages:
resolution: {integrity: sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==} resolution: {integrity: sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/core@3.36.2':
resolution: {integrity: sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.4.14': '@smithy/credential-provider-imds@4.4.14':
resolution: {integrity: sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==} resolution: {integrity: sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -1300,6 +1363,10 @@ packages:
resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.7.4':
resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.15.1': '@smithy/types@4.15.1':
resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
@@ -2798,10 +2865,10 @@ snapshots:
'@aws-sdk/checksums@3.1000.13': '@aws-sdk/checksums@3.1000.13':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.1
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.6
'@smithy/core': 3.33.3 '@smithy/core': 3.35.0
'@smithy/types': 4.17.2 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-api-gateway@3.1096.0': '@aws-sdk/client-api-gateway@3.1096.0':
@@ -2894,6 +2961,17 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/client-cognito-identity-provider@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-dynamodb@3.1112.0': '@aws-sdk/client-dynamodb@3.1112.0':
dependencies: dependencies:
'@aws-sdk/core': 3.977.8 '@aws-sdk/core': 3.977.8
@@ -3249,6 +3327,17 @@ snapshots:
bowser: 2.14.1 bowser: 2.14.1
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/core@3.978.5':
dependencies:
'@aws-sdk/types': 3.975.0
'@aws-sdk/xml-builder': 3.972.41
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.36.2
'@smithy/signature-v4': 5.7.4
'@smithy/types': 4.19.0
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.54': '@aws-sdk/credential-provider-env@3.972.54':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.0
@@ -3297,6 +3386,14 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.76':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.56': '@aws-sdk/credential-provider-http@3.972.56':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.0
@@ -3357,6 +3454,16 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.78':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.972.61': '@aws-sdk/credential-provider-ini@3.972.61':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.0
@@ -3437,6 +3544,22 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.21':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-env': 3.972.76
'@aws-sdk/credential-provider-http': 3.972.78
'@aws-sdk/credential-provider-login': 3.972.83
'@aws-sdk/credential-provider-process': 3.972.76
'@aws-sdk/credential-provider-sso': 3.973.20
'@aws-sdk/credential-provider-web-identity': 3.972.82
'@aws-sdk/nested-clients': 3.997.50
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.7': '@aws-sdk/credential-provider-ini@3.973.7':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.0
@@ -3507,6 +3630,15 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.83':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.50
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.63': '@aws-sdk/credential-provider-node@3.972.63':
dependencies: dependencies:
'@aws-sdk/credential-provider-env': 3.972.54 '@aws-sdk/credential-provider-env': 3.972.54
@@ -3605,6 +3737,20 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.88':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.76
'@aws-sdk/credential-provider-http': 3.972.78
'@aws-sdk/credential-provider-ini': 3.973.21
'@aws-sdk/credential-provider-process': 3.972.76
'@aws-sdk/credential-provider-sso': 3.973.20
'@aws-sdk/credential-provider-web-identity': 3.972.82
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.54': '@aws-sdk/credential-provider-process@3.972.54':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.0
@@ -3653,6 +3799,14 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.76':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.972.60': '@aws-sdk/credential-provider-sso@3.972.60':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.0
@@ -3703,6 +3857,16 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.20':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.50
'@aws-sdk/token-providers': 3.1155.0
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.6': '@aws-sdk/credential-provider-sso@3.973.6':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.0
@@ -3767,6 +3931,15 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.82':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.50
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/dynamodb-codec@3.973.43': '@aws-sdk/dynamodb-codec@3.973.43':
dependencies: dependencies:
'@aws-sdk/core': 3.978.0 '@aws-sdk/core': 3.978.0
@@ -3899,6 +4072,17 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.50':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/signature-v4-multi-region': 3.996.49
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.38': '@aws-sdk/signature-v4-multi-region@3.996.38':
dependencies: dependencies:
'@aws-sdk/types': 3.974.5 '@aws-sdk/types': 3.974.5
@@ -3920,6 +4104,13 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.49':
dependencies:
'@aws-sdk/types': 3.975.0
'@smithy/signature-v4': 5.7.4
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/token-providers@3.1080.0': '@aws-sdk/token-providers@3.1080.0':
dependencies: dependencies:
'@aws-sdk/core': 3.978.1 '@aws-sdk/core': 3.978.1
@@ -3974,6 +4165,15 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/token-providers@3.1155.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/nested-clients': 3.997.50
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/types@3.973.15': '@aws-sdk/types@3.973.15':
dependencies: dependencies:
'@smithy/types': 4.17.2 '@smithy/types': 4.17.2
@@ -3999,6 +4199,11 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@aws-sdk/types@3.975.0':
dependencies:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/util-dynamodb@3.996.9(@aws-sdk/client-dynamodb@3.1112.0)': '@aws-sdk/util-dynamodb@3.996.9(@aws-sdk/client-dynamodb@3.1112.0)':
dependencies: dependencies:
'@aws-sdk/client-dynamodb': 3.1112.0 '@aws-sdk/client-dynamodb': 3.1112.0
@@ -4496,6 +4701,11 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@smithy/core@3.36.2':
dependencies:
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.4.14': '@smithy/credential-provider-imds@4.4.14':
dependencies: dependencies:
'@smithy/core': 3.33.3 '@smithy/core': 3.33.3
@@ -4615,6 +4825,12 @@ snapshots:
'@smithy/types': 4.19.0 '@smithy/types': 4.19.0
tslib: 2.8.1 tslib: 2.8.1
'@smithy/signature-v4@5.7.4':
dependencies:
'@smithy/core': 3.36.2
'@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/types@4.15.1': '@smithy/types@4.15.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1