mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28af223a9f | ||
|
|
d0baf5b1df | ||
|
|
855c892f55 | ||
|
|
8ae4b88d80 | ||
|
|
a70ccddef5 | ||
|
|
b4d9b8c396 | ||
|
|
ce53275e9d | ||
|
|
7971a64e63 | ||
|
|
f39b4c74dc | ||
|
|
0ba8ab1ec7 | ||
|
|
039e57541e | ||
|
|
75f8c6ad7e | ||
|
|
a54dcbe949 | ||
|
|
c2b12cf21f | ||
|
|
4a9439e952 | ||
|
|
893e322a49 | ||
|
|
b0cb95be2f |
@@ -90,6 +90,7 @@ Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https:
|
||||
git clone https://github.com/simstudioai/sim.git
|
||||
cd sim
|
||||
bun install
|
||||
bun run prepare # Set up pre-commit hooks
|
||||
```
|
||||
|
||||
2. Set up PostgreSQL with pgvector:
|
||||
@@ -104,6 +105,11 @@ Or install manually via the [pgvector guide](https://github.com/pgvector/pgvecto
|
||||
|
||||
```bash
|
||||
cp apps/sim/.env.example apps/sim/.env
|
||||
# Create your secrets
|
||||
perl -i -pe "s/your_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
|
||||
perl -i -pe "s/your_internal_api_secret/$(openssl rand -hex 32)/" apps/sim/.env
|
||||
perl -i -pe "s/your_api_encryption_key/$(openssl rand -hex 32)/" apps/sim/.env
|
||||
# DB configs for migration
|
||||
cp packages/db/.env.example packages/db/.env
|
||||
# Edit both .env files to set DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
|
||||
```
|
||||
@@ -111,7 +117,7 @@ cp packages/db/.env.example packages/db/.env
|
||||
4. Run migrations:
|
||||
|
||||
```bash
|
||||
cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts
|
||||
cd packages/db && bun run db:migrate
|
||||
```
|
||||
|
||||
5. Start development servers:
|
||||
|
||||
@@ -124,6 +124,29 @@ export function ConditionalIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function CredentialIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg {...props} viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
||||
<circle cx='8' cy='15' r='4' stroke='currentColor' strokeWidth='1.75' />
|
||||
<path d='M11.83 13.17L20 5' stroke='currentColor' strokeWidth='1.75' strokeLinecap='round' />
|
||||
<path
|
||||
d='M18 7l2 2'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.75'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
<path
|
||||
d='M15 10l2 2'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.75'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoteIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
@@ -4630,6 +4653,59 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function CloudFormationIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 80 80'
|
||||
version='1.1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
xmlnsXlink='http://www.w3.org/1999/xlink'
|
||||
>
|
||||
<g
|
||||
id='Icon-Architecture/64/Arch_AWS-CloudFormation_64'
|
||||
stroke='none'
|
||||
strokeWidth='1'
|
||||
fill='none'
|
||||
fillRule='evenodd'
|
||||
>
|
||||
<path
|
||||
d='M53,39.9632039 L58,39.9632039 L58,37.9601375 L53,37.9601375 L53,39.9632039 Z M28,51.9816019 L33,51.9816019 L33,49.9785356 L28,49.9785356 L28,51.9816019 Z M18,51.9816019 L25,51.9816019 L25,49.9785356 L18,49.9785356 L18,51.9816019 Z M18,45.9724029 L30,45.9724029 L30,43.9693366 L18,43.9693366 L18,45.9724029 Z M18,33.9540048 L27,33.9540048 L27,31.9509385 L18,31.9509385 L18,33.9540048 Z M18,39.9632039 L51,39.9632039 L51,37.9601375 L18,37.9601375 L18,39.9632039 Z M37,61.9969337 L14,61.9969337 L14,27.9448058 L37,27.9448058 L37,35.9570712 L39,35.9570712 L39,26.9432726 C39,26.3904263 38.552,25.9417395 38,25.9417395 L13,25.9417395 C12.447,25.9417395 12,26.3904263 12,26.9432726 L12,62.9984668 C12,63.5513131 12.447,64 13,64 L38,64 C38.552,64 39,63.5513131 39,62.9984668 L39,42.9678034 L37,42.9678034 L37,61.9969337 Z M68,36.9586044 C68,43.4305117 62.173,45.6819583 59.092,45.9683968 L43,45.9724029 L43,43.9693366 L59,43.9693366 C59.195,43.9463013 66,43.2121775 66,36.9586044 C66,31.2638867 60.863,30.1081175 59.834,29.9338507 C59.321,29.8467173 58.96,29.3820059 59.004,28.8632117 C59.005,28.8441826 59.007,28.826155 59.009,28.8081274 C58.954,25.5902013 56.981,24.584662 56.126,24.3002266 C54.53,23.769414 52.751,24.2771913 51.81,25.5391231 C51.591,25.8355769 51.229,25.9868085 50.861,25.9307226 C50.497,25.8756383 50.192,25.625255 50.068,25.2767214 C49.447,23.5360568 48.546,22.4083304 47.293,21.1534094 C44.159,18.0386412 39.905,17.1783242 35.925,18.8528877 C33.837,19.7332353 32.012,21.7282894 30.922,24.327268 L29.078,23.5500782 C30.37,20.4743699 32.584,18.0887179 35.15,17.007062 C39.905,15.0049972 44.971,16.0255595 48.704,19.7342369 C49.774,20.8068789 50.66,21.851478 51.35,23.2035478 C52.843,22.0978551 54.857,21.7673492 56.757,22.3993166 C59.189,23.2085554 60.727,25.3207889 60.975,28.1290879 C64.381,28.9884034 68,31.7115721 68,36.9586044 L68,36.9586044 Z'
|
||||
id='AWS-CloudFormation_Icon_64_Squid'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CloudWatchIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 80 80'
|
||||
version='1.1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
xmlnsXlink='http://www.w3.org/1999/xlink'
|
||||
>
|
||||
<g
|
||||
id='Icon-Architecture/64/Arch_Amazon-CloudWatch_64'
|
||||
stroke='none'
|
||||
strokeWidth='1'
|
||||
fill='none'
|
||||
fillRule='evenodd'
|
||||
transform='translate(40, 40) scale(1.25) translate(-40, -40)'
|
||||
>
|
||||
<path
|
||||
d='M55.0592315,46.7773707 C55.0592315,42.8680281 51.8575588,39.6876305 47.9220646,39.6876305 C43.9865705,39.6876305 40.785903,42.8680281 40.785903,46.7773707 C40.785903,50.6867133 43.9865705,53.8671108 47.9220646,53.8671108 C51.8575588,53.8671108 55.0592315,50.6867133 55.0592315,46.7773707 M57.0697011,46.7773707 C57.0697011,51.7881194 52.9663327,55.8642207 47.9220646,55.8642207 C42.8788018,55.8642207 38.7754334,51.7881194 38.7754334,46.7773707 C38.7754334,41.7666219 42.8788018,37.6905206 47.9220646,37.6905206 C52.9663327,37.6905206 57.0697011,41.7666219 57.0697011,46.7773707 M65.5096522,60.4735504 L58.5011554,54.2026253 C57.9352082,54.9944794 57.2808004,55.7174332 56.5540156,56.3634982 L63.5524601,62.6334248 C64.1495696,63.1686502 65.0784065,63.1187225 65.6182176,62.5255808 C66.155013,61.9324392 66.1067617,61.010773 65.5096522,60.4735504 M47.9220646,57.6616197 C53.9645309,57.6616197 58.8801289,52.7786859 58.8801289,46.7773707 C58.8801289,40.7750569 53.9645309,35.8931217 47.9220646,35.8931217 C41.8806036,35.8931217 36.9650056,40.7750569 36.9650056,46.7773707 C36.9650056,52.7786859 41.8806036,57.6616197 47.9220646,57.6616197 M67.1119965,63.8626459 C66.4264264,64.6165549 65.47849,65 64.5285431,65 C63.7002296,65 62.8699057,64.708422 62.207456,64.1172774 L54.9305615,57.5987107 C52.9070239,58.8968321 50.505518,59.6587296 47.9220646,59.6587296 C40.7718297,59.6587296 34.9545361,53.8800921 34.9545361,46.7773707 C34.9545361,39.6746493 40.7718297,33.8960118 47.9220646,33.8960118 C55.0733048,33.8960118 60.8905985,39.6746493 60.8905985,46.7773707 C60.8905985,48.8154213 60.3990387,50.7366411 59.5465996,52.4511599 L66.8556616,58.9906963 C68.2750531,60.265851 68.3896499,62.4496907 67.1119965,63.8626459 M21.2803274,29.392529 C21.2803274,29.9117776 21.3124949,30.429029 21.3738143,30.9293051 C21.4089975,31.2138932 21.3205368,31.4984814 21.1295422,31.7131707 C20.9777518,31.8839236 20.7736891,31.9967603 20.550527,32.0347054 C18.0786547,32.6687878 14.0104695,34.5880104 14.0104695,40.3456782 C14.0104695,44.6933865 16.4240382,47.0929141 18.4495863,48.3411077 C19.1411878,48.7744806 19.9594489,49.0051468 20.8229456,49.0141338 L32.9450717,49.0251179 L32.9430613,51.0222278 L20.811888,51.0112437 C19.5664021,50.9982625 18.384246,50.6607509 17.3840374,50.0346569 C15.3765836,48.7974474 12,45.8896553 12,40.3456782 C12,33.66235 16.5999543,31.191925 19.3000149,30.319188 C19.2799102,30.0116331 19.2698579,29.702081 19.2698579,29.392529 C19.2698579,23.9324305 22.9982737,18.2696254 27.9420183,16.2215892 C33.7241287,13.8150717 39.8500294,15.0083449 44.3263399,19.4109737 C45.7135638,20.7749998 46.8545053,22.4316024 47.7300648,24.3478294 C48.9061895,23.3802296 50.355738,22.8460027 51.8836949,22.8460027 C54.8863312,22.8460027 58.2659305,25.1097268 58.8680661,30.0605622 C61.6797078,30.7046302 67.6206453,32.9553731 67.6206453,40.422567 C67.6206453,43.4042521 66.6797455,45.8666886 64.8230769,47.7419748 L63.3896121,46.3410022 C64.8632863,44.8531553 65.6101757,42.8620367 65.6101757,40.422567 C65.6101757,33.891019 60.1055101,32.2663701 57.737177,31.8719409 C57.4677741,31.827006 57.2295334,31.6752256 57.0757325,31.4515493 C56.9259525,31.2358614 56.8686541,30.9712444 56.9138897,30.7146157 C56.5851779,26.6604826 54.1605516,24.8431126 51.8836949,24.8431126 C50.4472144,24.8431126 49.1001998,25.5381069 48.1874466,26.7503526 C47.9652897,27.0439277 47.6044105,27.193711 47.2344841,27.139789 C46.8695838,27.085867 46.5629872,26.8362283 46.4373329,26.4917268 C45.6140456,24.2260057 44.4278686,22.3207628 42.9119745,20.8309188 C39.0327735,17.0154404 33.7281496,15.9809374 28.7170543,18.0649216 C24.5463352,19.7924217 21.2803274,24.7672224 21.2803274,29.392529'
|
||||
id='Amazon-CloudWatch_Icon_64_Squid'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
CirclebackIcon,
|
||||
ClayIcon,
|
||||
ClerkIcon,
|
||||
CloudFormationIcon,
|
||||
CloudflareIcon,
|
||||
CloudWatchIcon,
|
||||
ConfluenceIcon,
|
||||
CursorIcon,
|
||||
DatabricksIcon,
|
||||
@@ -211,6 +213,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
clay: ClayIcon,
|
||||
clerk: ClerkIcon,
|
||||
cloudflare: CloudflareIcon,
|
||||
cloudformation: CloudFormationIcon,
|
||||
cloudwatch: CloudWatchIcon,
|
||||
confluence_v2: ConfluenceIcon,
|
||||
cursor_v2: CursorIcon,
|
||||
databricks: DatabricksIcon,
|
||||
|
||||
183
apps/docs/content/docs/en/tools/cloudformation.mdx
Normal file
183
apps/docs/content/docs/en/tools/cloudformation.mdx
Normal file
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: CloudFormation
|
||||
description: Manage and inspect AWS CloudFormation stacks, resources, and drift
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="cloudformation"
|
||||
color="linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
[AWS CloudFormation](https://aws.amazon.com/cloudformation/) is an infrastructure-as-code service that lets you model, provision, and manage AWS resources by treating infrastructure as code. CloudFormation uses templates to describe the resources you need and their dependencies, so you can launch and configure them together as a stack.
|
||||
|
||||
With the CloudFormation integration, you can:
|
||||
|
||||
- **Describe Stacks**: List all stacks in a region or get detailed information about a specific stack, including its status, outputs, tags, and drift information
|
||||
- **List Stack Resources**: Enumerate every resource in a stack with its logical ID, physical ID, type, status, and drift status
|
||||
- **Describe Stack Events**: View the full event history for a stack to understand what happened during create, update, or delete operations
|
||||
- **Detect Stack Drift**: Initiate drift detection to check whether any resources in a stack have been modified outside of CloudFormation
|
||||
- **Drift Detection Status**: Poll the results of a drift detection operation to see which resources have drifted and how many
|
||||
- **Get Template**: Retrieve the original template body (JSON or YAML) used to create or update a stack
|
||||
- **Validate Template**: Check a CloudFormation template for syntax errors, required capabilities, parameters, and declared transforms before deploying
|
||||
|
||||
In Sim, the CloudFormation integration enables your agents to monitor infrastructure state, detect configuration drift, audit stack resources, and validate templates as part of automated SRE and DevOps workflows. This is especially powerful when combined with CloudWatch for observability and SNS for alerting, creating end-to-end infrastructure monitoring pipelines.
|
||||
{/* MANUAL-CONTENT-END */}
|
||||
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate AWS CloudFormation into workflows. Describe stacks, list resources, detect drift, view stack events, retrieve templates, and validate templates. Requires AWS access key and secret access key.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `cloudformation_describe_stacks`
|
||||
|
||||
List and describe CloudFormation stacks
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackName` | string | No | Stack name or ID to describe \(omit to list all stacks\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `stacks` | array | List of CloudFormation stacks with status, outputs, and tags |
|
||||
|
||||
### `cloudformation_list_stack_resources`
|
||||
|
||||
List all resources in a CloudFormation stack
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackName` | string | Yes | Stack name or ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `resources` | array | List of stack resources with type, status, and drift information |
|
||||
|
||||
### `cloudformation_detect_stack_drift`
|
||||
|
||||
Initiate drift detection on a CloudFormation stack
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackName` | string | Yes | Stack name or ID to detect drift on |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `stackDriftDetectionId` | string | ID to use with Describe Stack Drift Detection Status to check results |
|
||||
|
||||
### `cloudformation_describe_stack_drift_detection_status`
|
||||
|
||||
Check the status of a stack drift detection operation
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackDriftDetectionId` | string | Yes | The drift detection ID returned by Detect Stack Drift |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `stackId` | string | The stack ID |
|
||||
| `stackDriftDetectionId` | string | The drift detection ID |
|
||||
| `stackDriftStatus` | string | Drift status \(DRIFTED, IN_SYNC, NOT_CHECKED\) |
|
||||
| `detectionStatus` | string | Detection status \(DETECTION_IN_PROGRESS, DETECTION_COMPLETE, DETECTION_FAILED\) |
|
||||
| `detectionStatusReason` | string | Reason if detection failed |
|
||||
| `driftedStackResourceCount` | number | Number of resources that have drifted |
|
||||
| `timestamp` | number | Timestamp of the detection |
|
||||
|
||||
### `cloudformation_describe_stack_events`
|
||||
|
||||
Get the event history for a CloudFormation stack
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackName` | string | Yes | Stack name or ID |
|
||||
| `limit` | number | No | Maximum number of events to return \(default: 50\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `events` | array | List of stack events with resource status and timestamps |
|
||||
|
||||
### `cloudformation_get_template`
|
||||
|
||||
Retrieve the template body for a CloudFormation stack
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `stackName` | string | Yes | Stack name or ID |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `templateBody` | string | The template body as a JSON or YAML string |
|
||||
| `stagesAvailable` | array | Available template stages |
|
||||
|
||||
### `cloudformation_validate_template`
|
||||
|
||||
Validate a CloudFormation template for syntax and structural correctness
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `templateBody` | string | Yes | The CloudFormation template body \(JSON or YAML\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `description` | string | Template description |
|
||||
| `parameters` | array | Template parameters with defaults and descriptions |
|
||||
| `capabilities` | array | Required capabilities \(e.g., CAPABILITY_IAM\) |
|
||||
| `capabilitiesReason` | string | Reason capabilities are required |
|
||||
| `declaredTransforms` | array | Transforms used in the template \(e.g., AWS::Serverless-2016-10-31\) |
|
||||
|
||||
|
||||
180
apps/docs/content/docs/en/tools/cloudwatch.mdx
Normal file
180
apps/docs/content/docs/en/tools/cloudwatch.mdx
Normal file
@@ -0,0 +1,180 @@
|
||||
---
|
||||
title: CloudWatch
|
||||
description: Query and monitor AWS CloudWatch logs, metrics, and alarms
|
||||
---
|
||||
|
||||
import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
|
||||
<BlockInfoCard
|
||||
type="cloudwatch"
|
||||
color="linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)"
|
||||
/>
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
Integrate AWS CloudWatch into workflows. Run Log Insights queries, list log groups, retrieve log events, list and get metrics, and monitor alarms. Requires AWS access key and secret access key.
|
||||
|
||||
|
||||
|
||||
## Tools
|
||||
|
||||
### `cloudwatch_query_logs`
|
||||
|
||||
Run a CloudWatch Log Insights query against one or more log groups
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `logGroupNames` | array | Yes | Log group names to query |
|
||||
| `queryString` | string | Yes | CloudWatch Log Insights query string |
|
||||
| `startTime` | number | Yes | Start time as Unix epoch seconds |
|
||||
| `endTime` | number | Yes | End time as Unix epoch seconds |
|
||||
| `limit` | number | No | Maximum number of results to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `results` | array | Query result rows |
|
||||
| `statistics` | object | Query statistics \(bytesScanned, recordsMatched, recordsScanned\) |
|
||||
| `status` | string | Query completion status |
|
||||
|
||||
### `cloudwatch_describe_log_groups`
|
||||
|
||||
List available CloudWatch log groups
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `prefix` | string | No | Filter log groups by name prefix |
|
||||
| `limit` | number | No | Maximum number of log groups to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `logGroups` | array | List of CloudWatch log groups with metadata |
|
||||
|
||||
### `cloudwatch_get_log_events`
|
||||
|
||||
Retrieve log events from a specific CloudWatch log stream
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `logGroupName` | string | Yes | CloudWatch log group name |
|
||||
| `logStreamName` | string | Yes | CloudWatch log stream name |
|
||||
| `startTime` | number | No | Start time as Unix epoch seconds |
|
||||
| `endTime` | number | No | End time as Unix epoch seconds |
|
||||
| `limit` | number | No | Maximum number of events to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `events` | array | Log events with timestamp, message, and ingestion time |
|
||||
|
||||
### `cloudwatch_describe_log_streams`
|
||||
|
||||
List log streams within a CloudWatch log group
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `logGroupName` | string | Yes | CloudWatch log group name |
|
||||
| `prefix` | string | No | Filter log streams by name prefix |
|
||||
| `limit` | number | No | Maximum number of log streams to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `logStreams` | array | List of log streams with metadata |
|
||||
|
||||
### `cloudwatch_list_metrics`
|
||||
|
||||
List available CloudWatch metrics
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `namespace` | string | No | Filter by namespace \(e.g., AWS/EC2, AWS/Lambda\) |
|
||||
| `metricName` | string | No | Filter by metric name |
|
||||
| `recentlyActive` | boolean | No | Only show metrics active in the last 3 hours |
|
||||
| `limit` | number | No | Maximum number of metrics to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `metrics` | array | List of metrics with namespace, name, and dimensions |
|
||||
|
||||
### `cloudwatch_get_metric_statistics`
|
||||
|
||||
Get statistics for a CloudWatch metric over a time range
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `namespace` | string | Yes | Metric namespace \(e.g., AWS/EC2, AWS/Lambda\) |
|
||||
| `metricName` | string | Yes | Metric name \(e.g., CPUUtilization, Invocations\) |
|
||||
| `startTime` | number | Yes | Start time as Unix epoch seconds |
|
||||
| `endTime` | number | Yes | End time as Unix epoch seconds |
|
||||
| `period` | number | Yes | Granularity in seconds \(e.g., 60, 300, 3600\) |
|
||||
| `statistics` | array | Yes | Statistics to retrieve \(Average, Sum, Minimum, Maximum, SampleCount\) |
|
||||
| `dimensions` | string | No | Dimensions as JSON \(e.g., \{"InstanceId": "i-1234"\}\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `label` | string | Metric label |
|
||||
| `datapoints` | array | Datapoints with timestamp and statistics values |
|
||||
|
||||
### `cloudwatch_describe_alarms`
|
||||
|
||||
List and filter CloudWatch alarms
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `awsRegion` | string | Yes | AWS region \(e.g., us-east-1\) |
|
||||
| `awsAccessKeyId` | string | Yes | AWS access key ID |
|
||||
| `awsSecretAccessKey` | string | Yes | AWS secret access key |
|
||||
| `alarmNamePrefix` | string | No | Filter alarms by name prefix |
|
||||
| `stateValue` | string | No | Filter by alarm state \(OK, ALARM, INSUFFICIENT_DATA\) |
|
||||
| `alarmType` | string | No | Filter by alarm type \(MetricAlarm, CompositeAlarm\) |
|
||||
| `limit` | number | No | Maximum number of alarms to return |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `alarms` | array | List of CloudWatch alarms with state and configuration |
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"clay",
|
||||
"clerk",
|
||||
"cloudflare",
|
||||
"cloudformation",
|
||||
"cloudwatch",
|
||||
"confluence",
|
||||
"cursor",
|
||||
"databricks",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Database (Required)
|
||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres"
|
||||
|
||||
# PostgreSQL Port (Optional) - defaults to 5432 if not specified
|
||||
# POSTGRES_PORT=5432
|
||||
DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
|
||||
|
||||
# Authentication (Required unless DISABLE_AUTH=true)
|
||||
BETTER_AUTH_SECRET=your_secret_key # Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation
|
||||
|
||||
@@ -99,8 +99,6 @@ function SignupFormContent({
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const captchaResolveRef = useRef<((token: string) => void) | null>(null)
|
||||
const captchaRejectRef = useRef<((reason: Error) => void) | null>(null)
|
||||
const turnstileSiteKey = useMemo(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'), [])
|
||||
const redirectUrl = useMemo(
|
||||
() => searchParams.get('redirect') || searchParams.get('callbackUrl') || '',
|
||||
@@ -258,27 +256,14 @@ function SignupFormContent({
|
||||
let token: string | undefined
|
||||
const widget = turnstileRef.current
|
||||
if (turnstileSiteKey && widget) {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
widget.reset()
|
||||
token = await Promise.race([
|
||||
new Promise<string>((resolve, reject) => {
|
||||
captchaResolveRef.current = resolve
|
||||
captchaRejectRef.current = reject
|
||||
widget.execute()
|
||||
}),
|
||||
new Promise<string>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error('Captcha timed out')), 15_000)
|
||||
}),
|
||||
])
|
||||
widget.execute()
|
||||
token = await widget.getResponsePromise()
|
||||
} catch {
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
captchaResolveRef.current = null
|
||||
captchaRejectRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,10 +520,7 @@ function SignupFormContent({
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={(token) => captchaResolveRef.current?.(token)}
|
||||
onError={() => captchaRejectRef.current?.(new Error('Captcha verification failed'))}
|
||||
onExpire={() => captchaRejectRef.current?.(new Error('Captcha token expired'))}
|
||||
options={{ execution: 'execute' }}
|
||||
options={{ execution: 'execute', appearance: 'execute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
CirclebackIcon,
|
||||
ClayIcon,
|
||||
ClerkIcon,
|
||||
CloudFormationIcon,
|
||||
CloudflareIcon,
|
||||
CloudWatchIcon,
|
||||
ConfluenceIcon,
|
||||
CursorIcon,
|
||||
DatabricksIcon,
|
||||
@@ -211,6 +213,8 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
|
||||
clay: ClayIcon,
|
||||
clerk: ClerkIcon,
|
||||
cloudflare: CloudflareIcon,
|
||||
cloudformation: CloudFormationIcon,
|
||||
cloudwatch: CloudWatchIcon,
|
||||
confluence_v2: ConfluenceIcon,
|
||||
cursor_v2: CursorIcon,
|
||||
databricks: DatabricksIcon,
|
||||
|
||||
@@ -1912,6 +1912,100 @@
|
||||
"integrationType": "developer-tools",
|
||||
"tags": ["cloud", "monitoring"]
|
||||
},
|
||||
{
|
||||
"type": "cloudformation",
|
||||
"slug": "cloudformation",
|
||||
"name": "CloudFormation",
|
||||
"description": "Manage and inspect AWS CloudFormation stacks, resources, and drift",
|
||||
"longDescription": "Integrate AWS CloudFormation into workflows. Describe stacks, list resources, detect drift, view stack events, retrieve templates, and validate templates. Requires AWS access key and secret access key.",
|
||||
"bgColor": "linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)",
|
||||
"iconName": "CloudFormationIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/cloudformation",
|
||||
"operations": [
|
||||
{
|
||||
"name": "Describe Stacks",
|
||||
"description": "List and describe CloudFormation stacks"
|
||||
},
|
||||
{
|
||||
"name": "List Stack Resources",
|
||||
"description": "List all resources in a CloudFormation stack"
|
||||
},
|
||||
{
|
||||
"name": "Describe Stack Events",
|
||||
"description": "Get the event history for a CloudFormation stack"
|
||||
},
|
||||
{
|
||||
"name": "Detect Stack Drift",
|
||||
"description": "Initiate drift detection on a CloudFormation stack"
|
||||
},
|
||||
{
|
||||
"name": "Drift Detection Status",
|
||||
"description": "Check the status of a stack drift detection operation"
|
||||
},
|
||||
{
|
||||
"name": "Get Template",
|
||||
"description": "Retrieve the template body for a CloudFormation stack"
|
||||
},
|
||||
{
|
||||
"name": "Validate Template",
|
||||
"description": "Validate a CloudFormation template for syntax and structural correctness"
|
||||
}
|
||||
],
|
||||
"operationCount": 7,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "none",
|
||||
"category": "tools",
|
||||
"integrationType": "developer-tools",
|
||||
"tags": ["cloud"]
|
||||
},
|
||||
{
|
||||
"type": "cloudwatch",
|
||||
"slug": "cloudwatch",
|
||||
"name": "CloudWatch",
|
||||
"description": "Query and monitor AWS CloudWatch logs, metrics, and alarms",
|
||||
"longDescription": "Integrate AWS CloudWatch into workflows. Run Log Insights queries, list log groups, retrieve log events, list and get metrics, and monitor alarms. Requires AWS access key and secret access key.",
|
||||
"bgColor": "linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)",
|
||||
"iconName": "CloudWatchIcon",
|
||||
"docsUrl": "https://docs.sim.ai/tools/cloudwatch",
|
||||
"operations": [
|
||||
{
|
||||
"name": "Query Logs (Insights)",
|
||||
"description": "Run a CloudWatch Log Insights query against one or more log groups"
|
||||
},
|
||||
{
|
||||
"name": "Describe Log Groups",
|
||||
"description": "List available CloudWatch log groups"
|
||||
},
|
||||
{
|
||||
"name": "Get Log Events",
|
||||
"description": "Retrieve log events from a specific CloudWatch log stream"
|
||||
},
|
||||
{
|
||||
"name": "Describe Log Streams",
|
||||
"description": "List log streams within a CloudWatch log group"
|
||||
},
|
||||
{
|
||||
"name": "List Metrics",
|
||||
"description": "List available CloudWatch metrics"
|
||||
},
|
||||
{
|
||||
"name": "Get Metric Statistics",
|
||||
"description": "Get statistics for a CloudWatch metric over a time range"
|
||||
},
|
||||
{
|
||||
"name": "Describe Alarms",
|
||||
"description": "List and filter CloudWatch alarms"
|
||||
}
|
||||
],
|
||||
"operationCount": 7,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "none",
|
||||
"category": "tools",
|
||||
"integrationType": "analytics",
|
||||
"tags": ["cloud", "monitoring"]
|
||||
},
|
||||
{
|
||||
"type": "confluence_v2",
|
||||
"slug": "confluence",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
formatPrice,
|
||||
formatTokenCount,
|
||||
formatUpdatedAt,
|
||||
getEffectiveMaxOutputTokens,
|
||||
getModelBySlug,
|
||||
getPricingBounds,
|
||||
getProviderBySlug,
|
||||
@@ -198,7 +199,8 @@ export default async function ModelPage({
|
||||
</div>
|
||||
|
||||
<p className='max-w-[820px] text-[17px] text-[var(--landing-text-muted)] leading-relaxed'>
|
||||
{model.summary} {model.bestFor}
|
||||
{model.summary}
|
||||
{model.bestFor ? ` ${model.bestFor}` : ''}
|
||||
</p>
|
||||
|
||||
<div className='mt-8 flex flex-wrap gap-3'>
|
||||
@@ -229,13 +231,11 @@ export default async function ModelPage({
|
||||
? `${formatPrice(model.pricing.cachedInput)}/1M`
|
||||
: 'N/A'
|
||||
}
|
||||
compact
|
||||
/>
|
||||
<StatCard label='Output price' value={`${formatPrice(model.pricing.output)}/1M`} />
|
||||
<StatCard
|
||||
label='Context window'
|
||||
value={model.contextWindow ? formatTokenCount(model.contextWindow) : 'Unknown'}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -280,12 +280,12 @@ export default async function ModelPage({
|
||||
label='Max output'
|
||||
value={
|
||||
model.capabilities.maxOutputTokens
|
||||
? `${formatTokenCount(model.capabilities.maxOutputTokens)} tokens`
|
||||
: 'Standard defaults'
|
||||
? `${formatTokenCount(getEffectiveMaxOutputTokens(model.capabilities))} tokens`
|
||||
: 'Not published'
|
||||
}
|
||||
/>
|
||||
<DetailItem label='Provider' value={provider.name} />
|
||||
<DetailItem label='Best for' value={model.bestFor} />
|
||||
{model.bestFor ? <DetailItem label='Best for' value={model.bestFor} /> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
49
apps/sim/app/(landing)/models/utils.test.ts
Normal file
49
apps/sim/app/(landing)/models/utils.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildModelCapabilityFacts, getEffectiveMaxOutputTokens, getModelBySlug } from './utils'
|
||||
|
||||
describe('model catalog capability facts', () => {
|
||||
it.concurrent(
|
||||
'shows structured outputs support and published max output tokens for gpt-4o',
|
||||
() => {
|
||||
const model = getModelBySlug('openai', 'gpt-4o')
|
||||
|
||||
expect(model).not.toBeNull()
|
||||
expect(model).toBeDefined()
|
||||
|
||||
const capabilityFacts = buildModelCapabilityFacts(model!)
|
||||
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
|
||||
const maxOutputTokens = capabilityFacts.find((fact) => fact.label === 'Max output tokens')
|
||||
|
||||
expect(getEffectiveMaxOutputTokens(model!.capabilities)).toBe(16384)
|
||||
expect(structuredOutputs?.value).toBe('Supported')
|
||||
expect(maxOutputTokens?.value).toBe('16k')
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('preserves native structured outputs labeling for claude models', () => {
|
||||
const model = getModelBySlug('anthropic', 'claude-sonnet-4-6')
|
||||
|
||||
expect(model).not.toBeNull()
|
||||
expect(model).toBeDefined()
|
||||
|
||||
const capabilityFacts = buildModelCapabilityFacts(model!)
|
||||
const structuredOutputs = capabilityFacts.find((fact) => fact.label === 'Structured outputs')
|
||||
|
||||
expect(structuredOutputs?.value).toBe('Supported (native)')
|
||||
})
|
||||
|
||||
it.concurrent('does not invent a max output token limit when one is not published', () => {
|
||||
expect(getEffectiveMaxOutputTokens({})).toBeNull()
|
||||
})
|
||||
|
||||
it.concurrent('keeps best-for copy for clearly differentiated models only', () => {
|
||||
const researchModel = getModelBySlug('google', 'deep-research-pro-preview-12-2025')
|
||||
const generalModel = getModelBySlug('xai', 'grok-4-latest')
|
||||
|
||||
expect(researchModel).not.toBeNull()
|
||||
expect(generalModel).not.toBeNull()
|
||||
|
||||
expect(researchModel?.bestFor).toContain('research workflows')
|
||||
expect(generalModel?.bestFor).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -112,7 +112,7 @@ export interface CatalogModel {
|
||||
capabilities: ModelCapabilities
|
||||
capabilityTags: string[]
|
||||
summary: string
|
||||
bestFor: string
|
||||
bestFor?: string
|
||||
searchText: string
|
||||
}
|
||||
|
||||
@@ -190,6 +190,14 @@ export function formatCapabilityBoolean(
|
||||
return value ? positive : negative
|
||||
}
|
||||
|
||||
function supportsCatalogStructuredOutputs(capabilities: ModelCapabilities): boolean {
|
||||
return !capabilities.deepResearch
|
||||
}
|
||||
|
||||
export function getEffectiveMaxOutputTokens(capabilities: ModelCapabilities): number | null {
|
||||
return capabilities.maxOutputTokens ?? null
|
||||
}
|
||||
|
||||
function trimTrailingZeros(value: string): string {
|
||||
return value.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
|
||||
}
|
||||
@@ -326,7 +334,7 @@ function buildCapabilityTags(capabilities: ModelCapabilities): string[] {
|
||||
tags.push('Tool choice')
|
||||
}
|
||||
|
||||
if (capabilities.nativeStructuredOutputs) {
|
||||
if (supportsCatalogStructuredOutputs(capabilities)) {
|
||||
tags.push('Structured outputs')
|
||||
}
|
||||
|
||||
@@ -365,7 +373,7 @@ function buildBestForLine(model: {
|
||||
pricing: PricingInfo
|
||||
capabilities: ModelCapabilities
|
||||
contextWindow: number | null
|
||||
}): string {
|
||||
}): string | null {
|
||||
const { pricing, capabilities, contextWindow } = model
|
||||
|
||||
if (capabilities.deepResearch) {
|
||||
@@ -376,10 +384,6 @@ function buildBestForLine(model: {
|
||||
return 'Best for reasoning-heavy tasks that need more deliberate model control.'
|
||||
}
|
||||
|
||||
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
|
||||
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
|
||||
}
|
||||
|
||||
if (contextWindow && contextWindow >= 1000000) {
|
||||
return 'Best for long-context retrieval, large documents, and high-memory workflows.'
|
||||
}
|
||||
@@ -388,7 +392,11 @@ function buildBestForLine(model: {
|
||||
return 'Best for production workflows that need reliable typed outputs.'
|
||||
}
|
||||
|
||||
return 'Best for general-purpose AI workflows inside Sim.'
|
||||
if (pricing.input <= 0.2 && pricing.output <= 1.25) {
|
||||
return 'Best for cost-sensitive automations, background tasks, and high-volume workloads.'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildModelSummary(
|
||||
@@ -437,6 +445,11 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
|
||||
const shortId = stripProviderPrefix(provider.id, model.id)
|
||||
const mergedCapabilities = { ...provider.capabilities, ...model.capabilities }
|
||||
const capabilityTags = buildCapabilityTags(mergedCapabilities)
|
||||
const bestFor = buildBestForLine({
|
||||
pricing: model.pricing,
|
||||
capabilities: mergedCapabilities,
|
||||
contextWindow: model.contextWindow ?? null,
|
||||
})
|
||||
const displayName = formatModelDisplayName(provider.id, model.id)
|
||||
const modelSlug = slugify(shortId)
|
||||
const href = `/models/${providerSlug}/${modelSlug}`
|
||||
@@ -461,11 +474,7 @@ const rawProviders = Object.values(PROVIDER_DEFINITIONS).map((provider) => {
|
||||
model.contextWindow ?? null,
|
||||
capabilityTags
|
||||
),
|
||||
bestFor: buildBestForLine({
|
||||
pricing: model.pricing,
|
||||
capabilities: mergedCapabilities,
|
||||
contextWindow: model.contextWindow ?? null,
|
||||
}),
|
||||
...(bestFor ? { bestFor } : {}),
|
||||
searchText: [
|
||||
provider.name,
|
||||
providerDisplayName,
|
||||
@@ -683,6 +692,7 @@ export function buildModelFaqs(provider: CatalogProvider, model: CatalogModel):
|
||||
|
||||
export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[] {
|
||||
const { capabilities } = model
|
||||
const supportsStructuredOutputs = supportsCatalogStructuredOutputs(capabilities)
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -711,7 +721,11 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
|
||||
},
|
||||
{
|
||||
label: 'Structured outputs',
|
||||
value: formatCapabilityBoolean(capabilities.nativeStructuredOutputs),
|
||||
value: supportsStructuredOutputs
|
||||
? capabilities.nativeStructuredOutputs
|
||||
? 'Supported (native)'
|
||||
: 'Supported'
|
||||
: 'Not supported',
|
||||
},
|
||||
{
|
||||
label: 'Tool choice',
|
||||
@@ -732,8 +746,8 @@ export function buildModelCapabilityFacts(model: CatalogModel): CapabilityFact[]
|
||||
{
|
||||
label: 'Max output tokens',
|
||||
value: capabilities.maxOutputTokens
|
||||
? formatTokenCount(capabilities.maxOutputTokens)
|
||||
: 'Standard defaults',
|
||||
? formatTokenCount(getEffectiveMaxOutputTokens(capabilities))
|
||||
: 'Not published',
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -752,8 +766,8 @@ export function getProviderCapabilitySummary(provider: CatalogProvider): Capabil
|
||||
const reasoningCount = provider.models.filter(
|
||||
(model) => model.capabilities.reasoningEffort || model.capabilities.thinking
|
||||
).length
|
||||
const structuredCount = provider.models.filter(
|
||||
(model) => model.capabilities.nativeStructuredOutputs
|
||||
const structuredCount = provider.models.filter((model) =>
|
||||
supportsCatalogStructuredOutputs(model.capabilities)
|
||||
).length
|
||||
const deepResearchCount = provider.models.filter(
|
||||
(model) => model.capabilities.deepResearch
|
||||
|
||||
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performDeleteFolder } from '@/lib/workflows/orchestration'
|
||||
import { checkForCircularReference } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -156,6 +157,13 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_deleted',
|
||||
{ workspace_id: existingFolder.workspaceId },
|
||||
{ groups: { workspace: existingFolder.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedItems: result.deletedItems,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FoldersAPI')
|
||||
@@ -145,6 +146,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info('Created new folder:', { id, name, workspaceId, parentId })
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_created',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { z } from 'zod'
|
||||
import { decryptApiKey } from '@/lib/api-key/crypto'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
|
||||
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
@@ -116,6 +117,20 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.data.syncIntervalMinutes !== undefined &&
|
||||
parsed.data.syncIntervalMinutes > 0 &&
|
||||
parsed.data.syncIntervalMinutes < 60
|
||||
) {
|
||||
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
|
||||
if (!canUseLiveSync) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Live sync requires a Max or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.data.sourceConfig !== undefined) {
|
||||
const existingRows = await db
|
||||
.select()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||
import { encryptApiKey } from '@/lib/api-key/crypto'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
|
||||
import { allocateTagSlots } from '@/lib/knowledge/constants'
|
||||
@@ -97,6 +98,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = parsed.data
|
||||
|
||||
if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) {
|
||||
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
|
||||
if (!canUseLiveSync) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Live sync requires a Max or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
|
||||
if (!connectorConfig) {
|
||||
return NextResponse.json(
|
||||
@@ -151,19 +162,39 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
const tagSlotMapping: Record<string, string> = {}
|
||||
let newTagSlots: Record<string, string> = {}
|
||||
|
||||
if (connectorConfig.tagDefinitions?.length) {
|
||||
const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? [])
|
||||
const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id))
|
||||
|
||||
const existingDefs = await db
|
||||
.select({ tagSlot: knowledgeBaseTagDefinitions.tagSlot })
|
||||
.select({
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
fieldType: knowledgeBaseTagDefinitions.fieldType,
|
||||
})
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
const usedSlots = new Set<string>(existingDefs.map((d) => d.tagSlot))
|
||||
const { mapping, skipped: skippedTags } = allocateTagSlots(enabledDefs, usedSlots)
|
||||
const existingByName = new Map(
|
||||
existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }])
|
||||
)
|
||||
|
||||
const defsNeedingSlots: typeof enabledDefs = []
|
||||
for (const td of enabledDefs) {
|
||||
const existing = existingByName.get(td.displayName)
|
||||
if (existing && existing.fieldType === td.fieldType) {
|
||||
tagSlotMapping[td.id] = existing.tagSlot
|
||||
} else {
|
||||
defsNeedingSlots.push(td)
|
||||
}
|
||||
}
|
||||
|
||||
const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots)
|
||||
Object.assign(tagSlotMapping, mapping)
|
||||
newTagSlots = mapping
|
||||
|
||||
for (const name of skippedTags) {
|
||||
logger.warn(`[${requestId}] No available slots for "${name}"`)
|
||||
@@ -197,7 +228,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
throw new Error('Knowledge base not found')
|
||||
}
|
||||
|
||||
for (const [semanticId, slot] of Object.entries(tagSlotMapping)) {
|
||||
for (const [semanticId, slot] of Object.entries(newTagSlots)) {
|
||||
const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)!
|
||||
await createTagDefinition(
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
retryDocumentProcessing,
|
||||
updateDocument,
|
||||
} from '@/lib/knowledge/documents/service'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils'
|
||||
|
||||
const logger = createLogger('DocumentByIdAPI')
|
||||
@@ -285,6 +286,14 @@ export async function DELETE(
|
||||
request: req,
|
||||
})
|
||||
|
||||
const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId ?? ''
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'knowledge_base_document_deleted',
|
||||
{ knowledge_base_id: knowledgeBaseId, workspace_id: kbWorkspaceId },
|
||||
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createEnvMock, databaseMock, loggerMock } from '@sim/testing'
|
||||
import { mockNextFetchResponse } from '@sim/testing/mocks'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('drizzle-orm')
|
||||
@@ -14,16 +15,6 @@ vi.mock('@/lib/knowledge/documents/utils', () => ({
|
||||
retryWithExponentialBackoff: (fn: any) => fn(),
|
||||
}))
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => createEnvMock())
|
||||
|
||||
import {
|
||||
@@ -178,17 +169,16 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
@@ -209,17 +199,16 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://api.openai.com/v1/embeddings',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
@@ -243,17 +232,16 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('api-version='),
|
||||
expect.any(Object)
|
||||
)
|
||||
@@ -273,17 +261,16 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/custom-embedding-model/embeddings?api-version=2024-12-01-preview',
|
||||
expect.any(Object)
|
||||
)
|
||||
@@ -311,13 +298,12 @@ describe('Knowledge Search Utils', () => {
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
mockNextFetchResponse({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Deployment not found',
|
||||
} as any)
|
||||
text: 'Deployment not found',
|
||||
})
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
@@ -332,13 +318,12 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
mockNextFetchResponse({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
text: async () => 'Rate limit exceeded',
|
||||
} as any)
|
||||
text: 'Rate limit exceeded',
|
||||
})
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
@@ -356,17 +341,16 @@ describe('Knowledge Search Utils', () => {
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
@@ -387,17 +371,16 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -77,6 +77,7 @@ vi.stubGlobal(
|
||||
{ embedding: [0.1, 0.2], index: 0 },
|
||||
{ embedding: [0.3, 0.4], index: 1 },
|
||||
],
|
||||
usage: { prompt_tokens: 2, total_tokens: 2 },
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -294,7 +295,7 @@ describe('Knowledge Utils', () => {
|
||||
it.concurrent('should return same length as input', async () => {
|
||||
const result = await generateEmbeddings(['a', 'b'])
|
||||
|
||||
expect(result.length).toBe(2)
|
||||
expect(result.embeddings.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should use Azure OpenAI when Azure config is provided', async () => {
|
||||
@@ -313,6 +314,7 @@ describe('Knowledge Utils', () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}),
|
||||
} as any)
|
||||
|
||||
@@ -342,6 +344,7 @@ describe('Knowledge Utils', () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}),
|
||||
} as any)
|
||||
|
||||
|
||||
@@ -159,16 +159,7 @@ export async function PATCH(
|
||||
}
|
||||
)
|
||||
}
|
||||
if (isUnread === false) {
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'task_marked_read',
|
||||
{ workspace_id: updatedChat.workspaceId },
|
||||
{
|
||||
groups: { workspace: updatedChat.workspaceId },
|
||||
}
|
||||
)
|
||||
} else if (isUnread === true) {
|
||||
if (isUnread === true) {
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'task_marked_unread',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
@@ -298,6 +299,13 @@ export async function DELETE(
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'scheduled_task_deleted',
|
||||
{ workspace_id: workspaceId ?? '' },
|
||||
workspaceId ? { groups: { workspace: workspaceId } } : undefined
|
||||
)
|
||||
|
||||
return NextResponse.json({ message: 'Schedule deleted successfully' })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting schedule`, error)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
@@ -277,6 +278,13 @@ export async function POST(req: NextRequest) {
|
||||
lifecycle,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'scheduled_task_created',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json(
|
||||
{ schedule: { id, status: 'active', cronExpression, nextRunAt } },
|
||||
{ status: 201 }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
CloudFormationClient,
|
||||
DescribeStackDriftDetectionStatusCommand,
|
||||
} from '@aws-sdk/client-cloudformation'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudFormationDescribeStackDriftDetectionStatus')
|
||||
|
||||
const DescribeStackDriftDetectionStatusSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
stackDriftDetectionId: z.string().min(1, 'Stack drift detection ID is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DescribeStackDriftDetectionStatusSchema.parse(body)
|
||||
|
||||
const client = new CloudFormationClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const command = new DescribeStackDriftDetectionStatusCommand({
|
||||
StackDriftDetectionId: validatedData.stackDriftDetectionId,
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
stackId: response.StackId ?? '',
|
||||
stackDriftDetectionId: response.StackDriftDetectionId ?? '',
|
||||
stackDriftStatus: response.StackDriftStatus,
|
||||
detectionStatus: response.DetectionStatus ?? 'UNKNOWN',
|
||||
detectionStatusReason: response.DetectionStatusReason,
|
||||
driftedStackResourceCount: response.DriftedStackResourceCount,
|
||||
timestamp: response.Timestamp?.getTime(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe stack drift detection status'
|
||||
logger.error('DescribeStackDriftDetectionStatus failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
CloudFormationClient,
|
||||
DescribeStackEventsCommand,
|
||||
type StackEvent,
|
||||
} from '@aws-sdk/client-cloudformation'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudFormationDescribeStackEvents')
|
||||
|
||||
const DescribeStackEventsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
stackName: z.string().min(1, 'Stack name is required'),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DescribeStackEventsSchema.parse(body)
|
||||
|
||||
const client = new CloudFormationClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const limit = validatedData.limit ?? 50
|
||||
|
||||
const allEvents: StackEvent[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const command = new DescribeStackEventsCommand({
|
||||
StackName: validatedData.stackName,
|
||||
...(nextToken && { NextToken: nextToken }),
|
||||
})
|
||||
const response = await client.send(command)
|
||||
allEvents.push(...(response.StackEvents ?? []))
|
||||
nextToken = allEvents.length >= limit ? undefined : response.NextToken
|
||||
} while (nextToken)
|
||||
|
||||
const events = allEvents.slice(0, limit).map((e) => ({
|
||||
stackId: e.StackId ?? '',
|
||||
eventId: e.EventId ?? '',
|
||||
stackName: e.StackName ?? '',
|
||||
logicalResourceId: e.LogicalResourceId,
|
||||
physicalResourceId: e.PhysicalResourceId,
|
||||
resourceType: e.ResourceType,
|
||||
resourceStatus: e.ResourceStatus,
|
||||
resourceStatusReason: e.ResourceStatusReason,
|
||||
timestamp: e.Timestamp?.getTime(),
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { events },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe CloudFormation stack events'
|
||||
logger.error('DescribeStackEvents failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
CloudFormationClient,
|
||||
DescribeStacksCommand,
|
||||
type Stack,
|
||||
} from '@aws-sdk/client-cloudformation'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudFormationDescribeStacks')
|
||||
|
||||
const DescribeStacksSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
stackName: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DescribeStacksSchema.parse(body)
|
||||
|
||||
const client = new CloudFormationClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const allStacks: Stack[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const command = new DescribeStacksCommand({
|
||||
...(validatedData.stackName && { StackName: validatedData.stackName }),
|
||||
...(nextToken && { NextToken: nextToken }),
|
||||
})
|
||||
const response = await client.send(command)
|
||||
allStacks.push(...(response.Stacks ?? []))
|
||||
nextToken = response.NextToken
|
||||
} while (nextToken)
|
||||
|
||||
const stacks = allStacks.map((s) => ({
|
||||
stackName: s.StackName ?? '',
|
||||
stackId: s.StackId ?? '',
|
||||
stackStatus: s.StackStatus ?? 'UNKNOWN',
|
||||
stackStatusReason: s.StackStatusReason,
|
||||
creationTime: s.CreationTime?.getTime(),
|
||||
lastUpdatedTime: s.LastUpdatedTime?.getTime(),
|
||||
description: s.Description,
|
||||
enableTerminationProtection: s.EnableTerminationProtection,
|
||||
driftInformation: s.DriftInformation
|
||||
? {
|
||||
stackDriftStatus: s.DriftInformation.StackDriftStatus,
|
||||
lastCheckTimestamp: s.DriftInformation.LastCheckTimestamp?.getTime(),
|
||||
}
|
||||
: null,
|
||||
outputs: (s.Outputs ?? []).map((o) => ({
|
||||
outputKey: o.OutputKey ?? '',
|
||||
outputValue: o.OutputValue ?? '',
|
||||
description: o.Description,
|
||||
})),
|
||||
tags: (s.Tags ?? []).map((t) => ({
|
||||
key: t.Key ?? '',
|
||||
value: t.Value ?? '',
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { stacks },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe CloudFormation stacks'
|
||||
logger.error('DescribeStacks failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CloudFormationClient, DetectStackDriftCommand } from '@aws-sdk/client-cloudformation'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudFormationDetectStackDrift')
|
||||
|
||||
const DetectStackDriftSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
stackName: z.string().min(1, 'Stack name is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DetectStackDriftSchema.parse(body)
|
||||
|
||||
const client = new CloudFormationClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const command = new DetectStackDriftCommand({
|
||||
StackName: validatedData.stackName,
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
if (!response.StackDriftDetectionId) {
|
||||
throw new Error('No drift detection ID returned')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
stackDriftDetectionId: response.StackDriftDetectionId,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to detect CloudFormation stack drift'
|
||||
logger.error('DetectStackDrift failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
53
apps/sim/app/api/tools/cloudformation/get-template/route.ts
Normal file
53
apps/sim/app/api/tools/cloudformation/get-template/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { CloudFormationClient, GetTemplateCommand } from '@aws-sdk/client-cloudformation'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudFormationGetTemplate')
|
||||
|
||||
const GetTemplateSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
stackName: z.string().min(1, 'Stack name is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = GetTemplateSchema.parse(body)
|
||||
|
||||
const client = new CloudFormationClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const command = new GetTemplateCommand({
|
||||
StackName: validatedData.stackName,
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
templateBody: response.TemplateBody ?? '',
|
||||
stagesAvailable: response.StagesAvailable ?? [],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to get CloudFormation template'
|
||||
logger.error('GetTemplate failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
CloudFormationClient,
|
||||
ListStackResourcesCommand,
|
||||
type StackResourceSummary,
|
||||
} from '@aws-sdk/client-cloudformation'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudFormationListStackResources')
|
||||
|
||||
const ListStackResourcesSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
stackName: z.string().min(1, 'Stack name is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = ListStackResourcesSchema.parse(body)
|
||||
|
||||
const client = new CloudFormationClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const allSummaries: StackResourceSummary[] = []
|
||||
let nextToken: string | undefined
|
||||
do {
|
||||
const command = new ListStackResourcesCommand({
|
||||
StackName: validatedData.stackName,
|
||||
...(nextToken && { NextToken: nextToken }),
|
||||
})
|
||||
const response = await client.send(command)
|
||||
allSummaries.push(...(response.StackResourceSummaries ?? []))
|
||||
nextToken = response.NextToken
|
||||
} while (nextToken)
|
||||
|
||||
const resources = allSummaries.map((r) => ({
|
||||
logicalResourceId: r.LogicalResourceId ?? '',
|
||||
physicalResourceId: r.PhysicalResourceId,
|
||||
resourceType: r.ResourceType ?? '',
|
||||
resourceStatus: r.ResourceStatus ?? 'UNKNOWN',
|
||||
resourceStatusReason: r.ResourceStatusReason,
|
||||
lastUpdatedTimestamp: r.LastUpdatedTimestamp?.getTime(),
|
||||
driftInformation: r.DriftInformation
|
||||
? {
|
||||
stackResourceDriftStatus: r.DriftInformation.StackResourceDriftStatus,
|
||||
lastCheckTimestamp: r.DriftInformation.LastCheckTimestamp?.getTime(),
|
||||
}
|
||||
: null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { resources },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to list CloudFormation stack resources'
|
||||
logger.error('ListStackResources failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CloudFormationClient, ValidateTemplateCommand } from '@aws-sdk/client-cloudformation'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudFormationValidateTemplate')
|
||||
|
||||
const ValidateTemplateSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
templateBody: z.string().min(1, 'Template body is required'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = ValidateTemplateSchema.parse(body)
|
||||
|
||||
const client = new CloudFormationClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const command = new ValidateTemplateCommand({
|
||||
TemplateBody: validatedData.templateBody,
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
description: response.Description,
|
||||
parameters: (response.Parameters ?? []).map((p) => ({
|
||||
parameterKey: p.ParameterKey,
|
||||
defaultValue: p.DefaultValue,
|
||||
noEcho: p.NoEcho,
|
||||
description: p.Description,
|
||||
})),
|
||||
capabilities: response.Capabilities ?? [],
|
||||
capabilitiesReason: response.CapabilitiesReason,
|
||||
declaredTransforms: response.DeclaredTransforms ?? [],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to validate CloudFormation template'
|
||||
logger.error('ValidateTemplate failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
96
apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts
Normal file
96
apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
type AlarmType,
|
||||
CloudWatchClient,
|
||||
DescribeAlarmsCommand,
|
||||
type StateValue,
|
||||
} from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeAlarms')
|
||||
|
||||
const DescribeAlarmsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
alarmNamePrefix: z.string().optional(),
|
||||
stateValue: z.preprocess(
|
||||
(v) => (v === '' ? undefined : v),
|
||||
z.enum(['OK', 'ALARM', 'INSUFFICIENT_DATA']).optional()
|
||||
),
|
||||
alarmType: z.preprocess(
|
||||
(v) => (v === '' ? undefined : v),
|
||||
z.enum(['MetricAlarm', 'CompositeAlarm']).optional()
|
||||
),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DescribeAlarmsSchema.parse(body)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const command = new DescribeAlarmsCommand({
|
||||
...(validatedData.alarmNamePrefix && { AlarmNamePrefix: validatedData.alarmNamePrefix }),
|
||||
...(validatedData.stateValue && { StateValue: validatedData.stateValue as StateValue }),
|
||||
...(validatedData.alarmType && { AlarmTypes: [validatedData.alarmType as AlarmType] }),
|
||||
...(validatedData.limit !== undefined && { MaxRecords: validatedData.limit }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const metricAlarms = (response.MetricAlarms ?? []).map((a) => ({
|
||||
alarmName: a.AlarmName ?? '',
|
||||
alarmArn: a.AlarmArn ?? '',
|
||||
stateValue: a.StateValue ?? 'UNKNOWN',
|
||||
stateReason: a.StateReason ?? '',
|
||||
metricName: a.MetricName,
|
||||
namespace: a.Namespace,
|
||||
comparisonOperator: a.ComparisonOperator,
|
||||
threshold: a.Threshold,
|
||||
evaluationPeriods: a.EvaluationPeriods,
|
||||
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
|
||||
}))
|
||||
|
||||
const compositeAlarms = (response.CompositeAlarms ?? []).map((a) => ({
|
||||
alarmName: a.AlarmName ?? '',
|
||||
alarmArn: a.AlarmArn ?? '',
|
||||
stateValue: a.StateValue ?? 'UNKNOWN',
|
||||
stateReason: a.StateReason ?? '',
|
||||
metricName: undefined,
|
||||
namespace: undefined,
|
||||
comparisonOperator: undefined,
|
||||
threshold: undefined,
|
||||
evaluationPeriods: undefined,
|
||||
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { alarms: [...metricAlarms, ...compositeAlarms] },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe CloudWatch alarms'
|
||||
logger.error('DescribeAlarms failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeLogGroups')
|
||||
|
||||
const DescribeLogGroupsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
prefix: z.string().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DescribeLogGroupsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const command = new DescribeLogGroupsCommand({
|
||||
...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }),
|
||||
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const logGroups = (response.logGroups ?? []).map((lg) => ({
|
||||
logGroupName: lg.logGroupName ?? '',
|
||||
arn: lg.arn ?? '',
|
||||
storedBytes: lg.storedBytes ?? 0,
|
||||
retentionInDays: lg.retentionInDays,
|
||||
creationTime: lg.creationTime,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { logGroups },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe CloudWatch log groups'
|
||||
logger.error('DescribeLogGroups failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeLogStreams')
|
||||
|
||||
const DescribeLogStreamsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
logGroupName: z.string().min(1, 'Log group name is required'),
|
||||
prefix: z.string().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DescribeLogStreamsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const result = await describeLogStreams(client, validatedData.logGroupName, {
|
||||
prefix: validatedData.prefix,
|
||||
limit: validatedData.limit,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { logStreams: result.logStreams },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe CloudWatch log streams'
|
||||
logger.error('DescribeLogStreams failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
60
apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts
Normal file
60
apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient, getLogEvents } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchGetLogEvents')
|
||||
|
||||
const GetLogEventsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
logGroupName: z.string().min(1, 'Log group name is required'),
|
||||
logStreamName: z.string().min(1, 'Log stream name is required'),
|
||||
startTime: z.number({ coerce: true }).int().optional(),
|
||||
endTime: z.number({ coerce: true }).int().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = GetLogEventsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const result = await getLogEvents(
|
||||
client,
|
||||
validatedData.logGroupName,
|
||||
validatedData.logStreamName,
|
||||
{
|
||||
startTime: validatedData.startTime,
|
||||
endTime: validatedData.endTime,
|
||||
limit: validatedData.limit,
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { events: result.events },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to get CloudWatch log events'
|
||||
logger.error('GetLogEvents failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudWatchGetMetricStatistics')
|
||||
|
||||
const GetMetricStatisticsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
namespace: z.string().min(1, 'Namespace is required'),
|
||||
metricName: z.string().min(1, 'Metric name is required'),
|
||||
startTime: z.number({ coerce: true }).int(),
|
||||
endTime: z.number({ coerce: true }).int(),
|
||||
period: z.number({ coerce: true }).int().min(1),
|
||||
statistics: z.array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])).min(1),
|
||||
dimensions: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = GetMetricStatisticsSchema.parse(body)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
let parsedDimensions: { Name: string; Value: string }[] | undefined
|
||||
if (validatedData.dimensions) {
|
||||
try {
|
||||
const dims = JSON.parse(validatedData.dimensions)
|
||||
if (Array.isArray(dims)) {
|
||||
parsedDimensions = dims.map((d: Record<string, string>) => ({
|
||||
Name: d.name,
|
||||
Value: d.value,
|
||||
}))
|
||||
} else if (typeof dims === 'object') {
|
||||
parsedDimensions = Object.entries(dims).map(([name, value]) => ({
|
||||
Name: name,
|
||||
Value: String(value),
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
throw new Error('Invalid dimensions JSON')
|
||||
}
|
||||
}
|
||||
|
||||
const command = new GetMetricStatisticsCommand({
|
||||
Namespace: validatedData.namespace,
|
||||
MetricName: validatedData.metricName,
|
||||
StartTime: new Date(validatedData.startTime * 1000),
|
||||
EndTime: new Date(validatedData.endTime * 1000),
|
||||
Period: validatedData.period,
|
||||
Statistics: validatedData.statistics,
|
||||
...(parsedDimensions && { Dimensions: parsedDimensions }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const datapoints = (response.Datapoints ?? [])
|
||||
.sort((a, b) => (a.Timestamp?.getTime() ?? 0) - (b.Timestamp?.getTime() ?? 0))
|
||||
.map((dp) => ({
|
||||
timestamp: dp.Timestamp ? dp.Timestamp.getTime() : 0,
|
||||
average: dp.Average,
|
||||
sum: dp.Sum,
|
||||
minimum: dp.Minimum,
|
||||
maximum: dp.Maximum,
|
||||
sampleCount: dp.SampleCount,
|
||||
unit: dp.Unit,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
label: response.Label ?? validatedData.metricName,
|
||||
datapoints,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to get CloudWatch metric statistics'
|
||||
logger.error('GetMetricStatistics failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
70
apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts
Normal file
70
apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { CloudWatchClient, ListMetricsCommand } from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudWatchListMetrics')
|
||||
|
||||
const ListMetricsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
namespace: z.string().optional(),
|
||||
metricName: z.string().optional(),
|
||||
recentlyActive: z.boolean().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = ListMetricsSchema.parse(body)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const limit = validatedData.limit ?? 500
|
||||
|
||||
const command = new ListMetricsCommand({
|
||||
...(validatedData.namespace && { Namespace: validatedData.namespace }),
|
||||
...(validatedData.metricName && { MetricName: validatedData.metricName }),
|
||||
...(validatedData.recentlyActive && { RecentlyActive: 'PT3H' }),
|
||||
...(limit <= 500 && { MaxResults: limit }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const metrics = (response.Metrics ?? []).slice(0, limit).map((m) => ({
|
||||
namespace: m.Namespace ?? '',
|
||||
metricName: m.MetricName ?? '',
|
||||
dimensions: (m.Dimensions ?? []).map((d) => ({
|
||||
name: d.Name ?? '',
|
||||
value: d.Value ?? '',
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { metrics },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to list CloudWatch metrics'
|
||||
logger.error('ListMetrics failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
71
apps/sim/app/api/tools/cloudwatch/query-logs/route.ts
Normal file
71
apps/sim/app/api/tools/cloudwatch/query-logs/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient, pollQueryResults } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchQueryLogs')
|
||||
|
||||
const QueryLogsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
logGroupNames: z.array(z.string().min(1)).min(1, 'At least one log group name is required'),
|
||||
queryString: z.string().min(1, 'Query string is required'),
|
||||
startTime: z.number({ coerce: true }).int(),
|
||||
endTime: z.number({ coerce: true }).int(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = QueryLogsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const startQueryCommand = new StartQueryCommand({
|
||||
logGroupNames: validatedData.logGroupNames,
|
||||
queryString: validatedData.queryString,
|
||||
startTime: validatedData.startTime,
|
||||
endTime: validatedData.endTime,
|
||||
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
|
||||
})
|
||||
|
||||
const startQueryResponse = await client.send(startQueryCommand)
|
||||
const queryId = startQueryResponse.queryId
|
||||
|
||||
if (!queryId) {
|
||||
throw new Error('Failed to start CloudWatch Log Insights query: no queryId returned')
|
||||
}
|
||||
|
||||
const result = await pollQueryResults(client, queryId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
results: result.results,
|
||||
statistics: result.statistics,
|
||||
status: result.status,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'CloudWatch Log Insights query failed'
|
||||
logger.error('QueryLogs failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
161
apps/sim/app/api/tools/cloudwatch/utils.ts
Normal file
161
apps/sim/app/api/tools/cloudwatch/utils.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
CloudWatchLogsClient,
|
||||
DescribeLogStreamsCommand,
|
||||
GetLogEventsCommand,
|
||||
GetQueryResultsCommand,
|
||||
type ResultField,
|
||||
} from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
|
||||
|
||||
interface AwsCredentials {
|
||||
region: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
|
||||
export function createCloudWatchLogsClient(config: AwsCredentials): CloudWatchLogsClient {
|
||||
return new CloudWatchLogsClient({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface PollOptions {
|
||||
maxWaitMs?: number
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
|
||||
interface PollResult {
|
||||
results: Record<string, string>[]
|
||||
statistics: {
|
||||
bytesScanned: number
|
||||
recordsMatched: number
|
||||
recordsScanned: number
|
||||
}
|
||||
status: string
|
||||
}
|
||||
|
||||
function parseResultFields(fields: ResultField[] | undefined): Record<string, string> {
|
||||
const record: Record<string, string> = {}
|
||||
if (!fields) return record
|
||||
for (const field of fields) {
|
||||
if (field.field && field.value !== undefined) {
|
||||
record[field.field] = field.value ?? ''
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
export async function pollQueryResults(
|
||||
client: CloudWatchLogsClient,
|
||||
queryId: string,
|
||||
options: PollOptions = {}
|
||||
): Promise<PollResult> {
|
||||
const { maxWaitMs = DEFAULT_EXECUTION_TIMEOUT_MS, pollIntervalMs = 1_000 } = options
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
const command = new GetQueryResultsCommand({ queryId })
|
||||
const response = await client.send(command)
|
||||
|
||||
const status = response.status ?? 'Unknown'
|
||||
|
||||
if (status === 'Complete') {
|
||||
return {
|
||||
results: (response.results ?? []).map(parseResultFields),
|
||||
statistics: {
|
||||
bytesScanned: response.statistics?.bytesScanned ?? 0,
|
||||
recordsMatched: response.statistics?.recordsMatched ?? 0,
|
||||
recordsScanned: response.statistics?.recordsScanned ?? 0,
|
||||
},
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'Failed' || status === 'Cancelled') {
|
||||
throw new Error(`CloudWatch Log Insights query ${status.toLowerCase()}`)
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
||||
}
|
||||
|
||||
// Timeout -- fetch one last time for partial results
|
||||
const finalResponse = await client.send(new GetQueryResultsCommand({ queryId }))
|
||||
return {
|
||||
results: (finalResponse.results ?? []).map(parseResultFields),
|
||||
statistics: {
|
||||
bytesScanned: finalResponse.statistics?.bytesScanned ?? 0,
|
||||
recordsMatched: finalResponse.statistics?.recordsMatched ?? 0,
|
||||
recordsScanned: finalResponse.statistics?.recordsScanned ?? 0,
|
||||
},
|
||||
status: `Timeout (last status: ${finalResponse.status ?? 'Unknown'})`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function describeLogStreams(
|
||||
client: CloudWatchLogsClient,
|
||||
logGroupName: string,
|
||||
options?: { prefix?: string; limit?: number }
|
||||
): Promise<{
|
||||
logStreams: {
|
||||
logStreamName: string
|
||||
lastEventTimestamp: number | undefined
|
||||
firstEventTimestamp: number | undefined
|
||||
creationTime: number | undefined
|
||||
storedBytes: number
|
||||
}[]
|
||||
}> {
|
||||
const hasPrefix = Boolean(options?.prefix)
|
||||
const command = new DescribeLogStreamsCommand({
|
||||
logGroupName,
|
||||
...(hasPrefix
|
||||
? { orderBy: 'LogStreamName', logStreamNamePrefix: options!.prefix }
|
||||
: { orderBy: 'LastEventTime', descending: true }),
|
||||
...(options?.limit !== undefined && { limit: options.limit }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
return {
|
||||
logStreams: (response.logStreams ?? []).map((ls) => ({
|
||||
logStreamName: ls.logStreamName ?? '',
|
||||
lastEventTimestamp: ls.lastEventTimestamp,
|
||||
firstEventTimestamp: ls.firstEventTimestamp,
|
||||
creationTime: ls.creationTime,
|
||||
storedBytes: ls.storedBytes ?? 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLogEvents(
|
||||
client: CloudWatchLogsClient,
|
||||
logGroupName: string,
|
||||
logStreamName: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number }
|
||||
): Promise<{
|
||||
events: {
|
||||
timestamp: number | undefined
|
||||
message: string | undefined
|
||||
ingestionTime: number | undefined
|
||||
}[]
|
||||
}> {
|
||||
const command = new GetLogEventsCommand({
|
||||
logGroupIdentifier: logGroupName,
|
||||
logStreamName,
|
||||
...(options?.startTime !== undefined && { startTime: options.startTime * 1000 }),
|
||||
...(options?.endTime !== undefined && { endTime: options.endTime * 1000 }),
|
||||
...(options?.limit !== undefined && { limit: options.limit }),
|
||||
startFromHead: true,
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
return {
|
||||
events: (response.events ?? []).map((e) => ({
|
||||
timestamp: e.timestamp,
|
||||
message: e.message,
|
||||
ingestionTime: e.ingestionTime,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,44 @@
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
export async function GET() {
|
||||
export function GET() {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const llmsContent = `# Sim
|
||||
const content = `# Sim
|
||||
|
||||
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.
|
||||
> Sim is the open-source platform to build AI agents and run your agentic workforce. Connect integrations and LLMs to deploy and orchestrate agentic workflows.
|
||||
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over 100,000 builders use Sim — from startups to Fortune 500 companies. SOC2 compliant.
|
||||
Sim lets teams create agents, workflows, knowledge bases, tables, and docs. It supports both product discovery pages and deeper technical documentation.
|
||||
|
||||
## Core Pages
|
||||
## Preferred URLs
|
||||
|
||||
- [Homepage](${baseUrl}): Product overview, features, and pricing
|
||||
- [Homepage](${baseUrl}): Product overview and primary entry point
|
||||
- [Integrations directory](${baseUrl}/integrations): Public catalog of integrations and automation capabilities
|
||||
- [Models directory](${baseUrl}/models): Public catalog of AI models, pricing, context windows, and capabilities
|
||||
- [Blog](${baseUrl}/blog): Announcements, guides, and product context
|
||||
- [Changelog](${baseUrl}/changelog): Product updates and release notes
|
||||
- [Sim Blog](${baseUrl}/blog): Announcements, insights, and guides
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Documentation](https://docs.sim.ai): Complete guides and API reference
|
||||
- [Quickstart](https://docs.sim.ai/quickstart): Get started in 5 minutes
|
||||
- [API Reference](https://docs.sim.ai/api): REST API documentation
|
||||
- [Documentation](https://docs.sim.ai): Product guides and technical reference
|
||||
- [Quickstart](https://docs.sim.ai/quickstart): Fastest path to getting started
|
||||
- [API Reference](https://docs.sim.ai/api): API documentation
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Workspace**: Container for workflows, data sources, and executions
|
||||
- **Workflow**: Directed graph of blocks defining an agentic process
|
||||
- **Block**: Individual step (LLM call, tool call, HTTP request, code execution)
|
||||
- **Block**: Individual step such as an LLM call, tool call, HTTP request, or code execution
|
||||
- **Trigger**: Event or schedule that initiates workflow execution
|
||||
- **Execution**: A single run of a workflow with logs and outputs
|
||||
- **Knowledge Base**: Vector-indexed document store for retrieval-augmented generation
|
||||
- **Knowledge Base**: Document store used for retrieval-augmented generation
|
||||
|
||||
## Capabilities
|
||||
|
||||
- AI agent creation and deployment
|
||||
- Agentic workflow orchestration
|
||||
- 1,000+ integrations (Slack, Gmail, Notion, Airtable, databases, and more)
|
||||
- Multi-model LLM orchestration (OpenAI, Anthropic, Google, Mistral, xAI, Perplexity)
|
||||
- Knowledge base creation with retrieval-augmented generation (RAG)
|
||||
- Integrations across business tools, databases, and communication platforms
|
||||
- Multi-model LLM orchestration
|
||||
- Knowledge bases and retrieval-augmented generation
|
||||
- Table creation and management
|
||||
- Document creation and processing
|
||||
- Scheduled and webhook-triggered executions
|
||||
@@ -45,24 +47,19 @@ Sim lets teams create agents, workflows, knowledge bases, tables, and docs. Over
|
||||
|
||||
- AI agent deployment and orchestration
|
||||
- Knowledge bases and RAG pipelines
|
||||
- Document creation and processing
|
||||
- Customer support automation
|
||||
- Internal operations (sales, marketing, legal, finance)
|
||||
- Internal operations workflows across sales, marketing, legal, and finance
|
||||
|
||||
## Links
|
||||
## Additional Links
|
||||
|
||||
- [GitHub Repository](https://github.com/simstudioai/sim): Open-source codebase
|
||||
- [Discord Community](https://discord.gg/Hr4UWYEcTT): Get help and connect with 100,000+ builders
|
||||
- [X/Twitter](https://x.com/simdotai): Product updates and announcements
|
||||
|
||||
## Optional
|
||||
|
||||
- [Careers](https://jobs.ashbyhq.com/sim): Join the Sim team
|
||||
- [Docs](https://docs.sim.ai): Canonical documentation source
|
||||
- [Terms of Service](${baseUrl}/terms): Legal terms
|
||||
- [Privacy Policy](${baseUrl}/privacy): Data handling practices
|
||||
- [Sitemap](${baseUrl}/sitemap.xml): Public URL inventory
|
||||
`
|
||||
|
||||
return new Response(llmsContent, {
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
|
||||
|
||||
@@ -8,6 +8,34 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const now = new Date()
|
||||
const integrationPages: MetadataRoute.Sitemap = integrations.map((integration) => ({
|
||||
url: `${baseUrl}/integrations/${integration.slug}`,
|
||||
lastModified: now,
|
||||
}))
|
||||
const modelHubPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: `${baseUrl}/integrations`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/models`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/partners`,
|
||||
lastModified: now,
|
||||
},
|
||||
]
|
||||
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
url: `${baseUrl}${provider.href}`,
|
||||
lastModified: new Date(
|
||||
Math.max(...provider.models.map((model) => new Date(model.pricing.updatedAt).getTime()))
|
||||
),
|
||||
}))
|
||||
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
|
||||
url: `${baseUrl}${model.href}`,
|
||||
lastModified: new Date(model.pricing.updatedAt),
|
||||
}))
|
||||
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
@@ -26,14 +54,6 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
// url: `${baseUrl}/templates`,
|
||||
// lastModified: now,
|
||||
// },
|
||||
{
|
||||
url: `${baseUrl}/integrations`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/models`,
|
||||
lastModified: now,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/changelog`,
|
||||
lastModified: now,
|
||||
@@ -54,20 +74,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
lastModified: new Date(p.updated ?? p.date),
|
||||
}))
|
||||
|
||||
const integrationPages: MetadataRoute.Sitemap = integrations.map((i) => ({
|
||||
url: `${baseUrl}/integrations/${i.slug}`,
|
||||
lastModified: now,
|
||||
}))
|
||||
|
||||
const providerPages: MetadataRoute.Sitemap = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
url: `${baseUrl}${provider.href}`,
|
||||
lastModified: now,
|
||||
}))
|
||||
|
||||
const modelPages: MetadataRoute.Sitemap = ALL_CATALOG_MODELS.map((model) => ({
|
||||
url: `${baseUrl}${model.href}`,
|
||||
lastModified: new Date(model.pricing.updatedAt),
|
||||
}))
|
||||
|
||||
return [...staticPages, ...blogPages, ...integrationPages, ...providerPages, ...modelPages]
|
||||
return [
|
||||
...staticPages,
|
||||
...modelHubPages,
|
||||
...integrationPages,
|
||||
...providerPages,
|
||||
...modelPages,
|
||||
...blogPages,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,22 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Check, Copy, Ellipsis, Hash } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Button,
|
||||
Check,
|
||||
Copy,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
} from '@/components/emcn'
|
||||
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
|
||||
|
||||
const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
|
||||
|
||||
function toPlainText(raw: string): string {
|
||||
return (
|
||||
raw
|
||||
// Strip special tags and their contents
|
||||
.replace(new RegExp(`<\\/?(${SPECIAL_TAGS})(?:>[\\s\\S]*?<\\/(${SPECIAL_TAGS})>|>)`, 'g'), '')
|
||||
// Strip markdown
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/`{3}[\s\S]*?`{3}/g, '')
|
||||
.replace(/`(.+?)`/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/^[>\-*]\s+/gm, '')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, '')
|
||||
// Normalize whitespace
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
)
|
||||
}
|
||||
|
||||
const ICON_CLASS = 'h-[14px] w-[14px]'
|
||||
const BUTTON_CLASS =
|
||||
'flex h-[26px] w-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
|
||||
|
||||
interface MessageActionsProps {
|
||||
content: string
|
||||
requestId?: string
|
||||
chatId?: string
|
||||
userQuery?: string
|
||||
}
|
||||
|
||||
export function MessageActions({ content, requestId }: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState<'message' | 'request' | null>(null)
|
||||
export function MessageActions({ content, chatId, userQuery }: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [pendingFeedback, setPendingFeedback] = useState<'up' | 'down' | null>(null)
|
||||
const [feedbackText, setFeedbackText] = useState('')
|
||||
const resetTimeoutRef = useRef<number | null>(null)
|
||||
const submitFeedback = useSubmitCopilotFeedback()
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -26,59 +63,119 @@ export function MessageActions({ content, requestId }: MessageActionsProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const copyToClipboard = useCallback(async (text: string, type: 'message' | 'request') => {
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (!content) return
|
||||
const text = toPlainText(content)
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(type)
|
||||
setCopied(true)
|
||||
if (resetTimeoutRef.current !== null) {
|
||||
window.clearTimeout(resetTimeoutRef.current)
|
||||
}
|
||||
resetTimeoutRef.current = window.setTimeout(() => setCopied(null), 1500)
|
||||
resetTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
}
|
||||
}, [content])
|
||||
|
||||
const handleFeedbackClick = useCallback(
|
||||
(type: 'up' | 'down') => {
|
||||
if (chatId && userQuery) {
|
||||
setPendingFeedback(type)
|
||||
setFeedbackText('')
|
||||
}
|
||||
},
|
||||
[chatId, userQuery]
|
||||
)
|
||||
|
||||
const handleSubmitFeedback = useCallback(() => {
|
||||
if (!pendingFeedback || !chatId || !userQuery) return
|
||||
const text = feedbackText.trim()
|
||||
if (!text) {
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
return
|
||||
}
|
||||
submitFeedback.mutate({
|
||||
chatId,
|
||||
userQuery,
|
||||
agentResponse: content,
|
||||
isPositiveFeedback: pendingFeedback === 'up',
|
||||
feedback: text,
|
||||
})
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
}, [pendingFeedback, chatId, userQuery, content, feedbackText])
|
||||
|
||||
const handleModalClose = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setPendingFeedback(null)
|
||||
setFeedbackText('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!content && !requestId) {
|
||||
return null
|
||||
}
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<>
|
||||
<div className='flex items-center gap-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='More options'
|
||||
className='flex h-5 w-5 items-center justify-center rounded-sm text-[var(--text-icon)] opacity-0 transition-colors transition-opacity hover-hover:bg-[var(--surface-3)] hover-hover:text-[var(--text-primary)] focus-visible:opacity-100 focus-visible:outline-none group-hover/msg:opacity-100 data-[state=open]:opacity-100'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label='Copy message'
|
||||
onClick={copyToClipboard}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
<Ellipsis className='h-3 w-3' strokeWidth={2} />
|
||||
{copied ? <Check className={ICON_CLASS} /> : <Copy className={ICON_CLASS} />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' side='top' sideOffset={4}>
|
||||
<DropdownMenuItem
|
||||
disabled={!content}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation()
|
||||
void copyToClipboard(content, 'message')
|
||||
}}
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Like'
|
||||
onClick={() => handleFeedbackClick('up')}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
{copied === 'message' ? <Check /> : <Copy />}
|
||||
<span>Copy Message</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!requestId}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation()
|
||||
if (requestId) {
|
||||
void copyToClipboard(requestId, 'request')
|
||||
}
|
||||
}}
|
||||
<ThumbsUp className={ICON_CLASS} />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
aria-label='Dislike'
|
||||
onClick={() => handleFeedbackClick('down')}
|
||||
className={BUTTON_CLASS}
|
||||
>
|
||||
{copied === 'request' ? <Check /> : <Hash />}
|
||||
<span>Copy Request ID</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ThumbsDown className={ICON_CLASS} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Modal open={pendingFeedback !== null} onOpenChange={handleModalClose}>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Give feedback</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?'}
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder={
|
||||
pendingFeedback === 'up'
|
||||
? 'Tell us what was helpful...'
|
||||
: 'Tell us what went wrong...'
|
||||
}
|
||||
value={feedbackText}
|
||||
onChange={(e) => setFeedbackText(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='default' onClick={() => handleModalClose(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleSubmitFeedback}>
|
||||
Submit
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, memo, useContext, useMemo, useRef } from 'react'
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { Components, ExtraProps } from 'react-markdown'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import rehypeSlug from 'rehype-slug'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Checkbox } from '@/components/emcn'
|
||||
@@ -70,6 +72,7 @@ export const PreviewPanel = memo(function PreviewPanel({
|
||||
})
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
|
||||
const REHYPE_PLUGINS = [rehypeSlug]
|
||||
|
||||
/**
|
||||
* Carries the contentRef and toggle handler from MarkdownPreview down to the
|
||||
@@ -83,29 +86,43 @@ const MarkdownCheckboxCtx = createContext<{
|
||||
/** Carries the resolved checkbox index from LiRenderer to InputRenderer. */
|
||||
const CheckboxIndexCtx = createContext(-1)
|
||||
|
||||
const NavigateCtx = createContext<((path: string) => void) | null>(null)
|
||||
|
||||
const STATIC_MARKDOWN_COMPONENTS = {
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className='mb-3 break-words text-[14px] text-[var(--text-primary)] leading-[1.6] last:mb-0'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
h1: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h1 className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'>
|
||||
h1: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h1
|
||||
id={id}
|
||||
className='mt-6 mb-4 break-words font-semibold text-[24px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h2 className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'>
|
||||
h2: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h2
|
||||
id={id}
|
||||
className='mt-5 mb-3 break-words font-semibold text-[20px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h3 className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'>
|
||||
h3: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h3
|
||||
id={id}
|
||||
className='mt-4 mb-2 break-words font-semibold text-[16px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h4 className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'>
|
||||
h4: ({ id, children }: { id?: string; children?: React.ReactNode }) => (
|
||||
<h4
|
||||
id={id}
|
||||
className='mt-3 mb-2 break-words font-semibold text-[14px] text-[var(--text-primary)] first:mt-0'
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
@@ -138,16 +155,6 @@ const STATIC_MARKDOWN_COMPONENTS = {
|
||||
)
|
||||
},
|
||||
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
|
||||
),
|
||||
@@ -267,8 +274,75 @@ function InputRenderer({
|
||||
)
|
||||
}
|
||||
|
||||
function isInternalHref(
|
||||
href: string,
|
||||
origin = window.location.origin
|
||||
): { pathname: string; hash: string } | null {
|
||||
if (href.startsWith('#')) return { pathname: '', hash: href }
|
||||
try {
|
||||
const url = new URL(href, origin)
|
||||
if (url.origin === origin && url.pathname.startsWith('/workspace/')) {
|
||||
return { pathname: url.pathname, hash: url.hash }
|
||||
}
|
||||
} catch {
|
||||
if (href.startsWith('/workspace/')) {
|
||||
const hashIdx = href.indexOf('#')
|
||||
if (hashIdx === -1) return { pathname: href, hash: '' }
|
||||
return { pathname: href.slice(0, hashIdx), hash: href.slice(hashIdx) }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function AnchorRenderer({ href, children }: { href?: string; children?: React.ReactNode }) {
|
||||
const navigate = useContext(NavigateCtx)
|
||||
const parsed = useMemo(() => (href ? isInternalHref(href) : null), [href])
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!parsed || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
if (parsed.pathname === '' && parsed.hash) {
|
||||
const el = document.getElementById(parsed.hash.slice(1))
|
||||
if (el) {
|
||||
const container = el.closest('.overflow-auto') as HTMLElement | null
|
||||
if (container) {
|
||||
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const destination = parsed.pathname + parsed.hash
|
||||
if (navigate) {
|
||||
navigate(destination)
|
||||
} else {
|
||||
window.location.assign(destination)
|
||||
}
|
||||
},
|
||||
[parsed, navigate]
|
||||
)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={parsed ? undefined : '_blank'}
|
||||
rel={parsed ? undefined : 'noopener noreferrer'}
|
||||
onClick={handleClick}
|
||||
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover:underline'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS = {
|
||||
...STATIC_MARKDOWN_COMPONENTS,
|
||||
a: AnchorRenderer,
|
||||
ul: UlRenderer,
|
||||
ol: OlRenderer,
|
||||
li: LiRenderer,
|
||||
@@ -284,6 +358,7 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
isStreaming?: boolean
|
||||
onCheckboxToggle?: (checkboxIndex: number, checked: boolean) => void
|
||||
}) {
|
||||
const { push: navigate } = useRouter()
|
||||
const { ref: scrollRef } = useAutoScroll(isStreaming)
|
||||
const { committed, incoming, generation } = useStreamingReveal(content, isStreaming)
|
||||
|
||||
@@ -295,10 +370,30 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
[onCheckboxToggle]
|
||||
)
|
||||
|
||||
const hasScrolledToHash = useRef(false)
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
if (!hash || hasScrolledToHash.current) return
|
||||
const id = hash.slice(1)
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
hasScrolledToHash.current = true
|
||||
const container = el.closest('.overflow-auto') as HTMLElement | null
|
||||
if (container) {
|
||||
container.scrollTo({ top: el.offsetTop - container.offsetTop, behavior: 'smooth' })
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [content])
|
||||
|
||||
const committedMarkdown = useMemo(
|
||||
() =>
|
||||
committed ? (
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{committed}
|
||||
</ReactMarkdown>
|
||||
) : null,
|
||||
@@ -307,30 +402,42 @@ const MarkdownPreview = memo(function MarkdownPreview({
|
||||
|
||||
if (onCheckboxToggle) {
|
||||
return (
|
||||
<MarkdownCheckboxCtx.Provider value={ctxValue}>
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</MarkdownCheckboxCtx.Provider>
|
||||
<NavigateCtx.Provider value={navigate}>
|
||||
<MarkdownCheckboxCtx.Provider value={ctxValue}>
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</MarkdownCheckboxCtx.Provider>
|
||||
</NavigateCtx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
{committedMarkdown}
|
||||
{incoming && (
|
||||
<div
|
||||
key={generation}
|
||||
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
|
||||
{incoming}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NavigateCtx.Provider value={navigate}>
|
||||
<div ref={scrollRef} className='h-full overflow-auto p-6'>
|
||||
{committedMarkdown}
|
||||
{incoming && (
|
||||
<div
|
||||
key={generation}
|
||||
className={cn(isStreaming && 'animate-stream-fade-in', '[&>:first-child]:mt-0')}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{incoming}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NavigateCtx.Provider>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -473,9 +473,9 @@ function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
|
||||
const detail = data.code ? `${data.message} (${data.code})` : data.message
|
||||
|
||||
return (
|
||||
<span className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
|
||||
<p className='animate-stream-fade-in font-base text-[13px] text-[var(--text-secondary)] italic leading-[20px]'>
|
||||
{detail}
|
||||
</span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ interface MothershipChatProps {
|
||||
onSendQueuedMessage: (id: string) => Promise<void>
|
||||
onEditQueuedMessage: (id: string) => void
|
||||
userId?: string
|
||||
chatId?: string
|
||||
onContextAdd?: (context: ChatContext) => void
|
||||
editValue?: string
|
||||
onEditValueConsumed?: () => void
|
||||
@@ -53,7 +54,7 @@ const LAYOUT_STYLES = {
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-3',
|
||||
attachmentWidth: 'max-w-[70%]',
|
||||
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
|
||||
assistantRow: 'group/msg relative pb-5',
|
||||
assistantRow: 'group/msg',
|
||||
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
|
||||
footerInner: 'mx-auto max-w-[42rem]',
|
||||
},
|
||||
@@ -63,7 +64,7 @@ const LAYOUT_STYLES = {
|
||||
userRow: 'flex flex-col items-end gap-[6px] pt-2',
|
||||
attachmentWidth: 'max-w-[85%]',
|
||||
userBubble: 'max-w-[85%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3 py-2',
|
||||
assistantRow: 'group/msg relative pb-3',
|
||||
assistantRow: 'group/msg',
|
||||
footer: 'flex-shrink-0 px-3 pb-3',
|
||||
footerInner: '',
|
||||
},
|
||||
@@ -80,6 +81,7 @@ export function MothershipChat({
|
||||
onSendQueuedMessage,
|
||||
onEditQueuedMessage,
|
||||
userId,
|
||||
chatId,
|
||||
onContextAdd,
|
||||
editValue,
|
||||
onEditValueConsumed,
|
||||
@@ -147,20 +149,28 @@ export function MothershipChat({
|
||||
}
|
||||
|
||||
const isLastMessage = index === messages.length - 1
|
||||
const precedingUserMsg = [...messages]
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.find((m) => m.role === 'user')
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={styles.assistantRow}>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='absolute right-0 bottom-0 z-10'>
|
||||
<MessageActions content={msg.content} requestId={msg.requestId} />
|
||||
</div>
|
||||
)}
|
||||
<MessageContent
|
||||
blocks={msg.contentBlocks || []}
|
||||
fallbackContent={msg.content}
|
||||
isStreaming={isThisStreaming}
|
||||
onOptionSelect={isLastMessage ? onSubmit : undefined}
|
||||
/>
|
||||
{!isThisStreaming && (msg.content || msg.contentBlocks?.length) && (
|
||||
<div className='mt-2.5'>
|
||||
<MessageActions
|
||||
content={msg.content}
|
||||
chatId={chatId}
|
||||
userQuery={precedingUserMsg?.content}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -115,7 +115,7 @@ export const MothershipView = memo(
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
'relative z-10 flex h-full flex-col overflow-hidden border-[var(--border)] bg-[var(--bg)] transition-[width,min-width,border-width] duration-200 ease-[cubic-bezier(0.25,0.1,0.25,1)]',
|
||||
isCollapsed ? 'w-0 min-w-0 border-l-0' : 'w-1/2 border-l',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { PanelLeft } from '@/components/emcn/icons'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
@@ -28,6 +28,8 @@ interface HomeProps {
|
||||
export function Home({ chatId }: HomeProps = {}) {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const initialResourceId = searchParams.get('resource')
|
||||
const { data: session } = useSession()
|
||||
const posthog = usePostHog()
|
||||
const posthogRef = useRef(posthog)
|
||||
@@ -160,7 +162,10 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
} = useChat(
|
||||
workspaceId,
|
||||
chatId,
|
||||
getMothershipUseChatOptions({ onResourceEvent: handleResourceEvent })
|
||||
getMothershipUseChatOptions({
|
||||
onResourceEvent: handleResourceEvent,
|
||||
initialActiveResourceId: initialResourceId,
|
||||
})
|
||||
)
|
||||
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
@@ -183,6 +188,16 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
[editQueuedMessage]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
if (activeResourceId) {
|
||||
url.searchParams.set('resource', activeResourceId)
|
||||
} else {
|
||||
url.searchParams.delete('resource')
|
||||
}
|
||||
window.history.replaceState(null, '', url.toString())
|
||||
}, [activeResourceId])
|
||||
|
||||
useEffect(() => {
|
||||
wasSendingRef.current = false
|
||||
if (resolvedChatId) markRead(resolvedChatId)
|
||||
@@ -213,6 +228,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
|
||||
|
||||
captureEvent(posthogRef.current, 'task_message_sent', {
|
||||
workspace_id: workspaceId,
|
||||
has_attachments: !!(fileAttachments && fileAttachments.length > 0),
|
||||
has_contexts: !!(contexts && contexts.length > 0),
|
||||
is_new_task: !chatId,
|
||||
@@ -224,7 +240,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
|
||||
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
|
||||
},
|
||||
[sendMessage]
|
||||
[sendMessage, workspaceId, chatId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -348,6 +364,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
onSendQueuedMessage={sendNow}
|
||||
onEditQueuedMessage={handleEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
chatId={resolvedChatId}
|
||||
onContextAdd={handleContextAdd}
|
||||
editValue={editingInputValue}
|
||||
onEditValueConsumed={clearEditingValue}
|
||||
|
||||
@@ -377,10 +377,11 @@ export interface UseChatOptions {
|
||||
onToolResult?: (toolName: string, success: boolean, result: unknown) => void
|
||||
onTitleUpdate?: () => void
|
||||
onStreamEnd?: (chatId: string, messages: ChatMessage[]) => void
|
||||
initialActiveResourceId?: string | null
|
||||
}
|
||||
|
||||
export function getMothershipUseChatOptions(
|
||||
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd'> = {}
|
||||
options: Pick<UseChatOptions, 'onResourceEvent' | 'onStreamEnd' | 'initialActiveResourceId'> = {}
|
||||
): UseChatOptions {
|
||||
return {
|
||||
apiPath: MOTHERSHIP_CHAT_API_PATH,
|
||||
@@ -416,6 +417,7 @@ export function useChat(
|
||||
const [resolvedChatId, setResolvedChatId] = useState<string | undefined>(initialChatId)
|
||||
const [resources, setResources] = useState<MothershipResource[]>([])
|
||||
const [activeResourceId, setActiveResourceId] = useState<string | null>(null)
|
||||
const initialActiveResourceIdRef = useRef(options?.initialActiveResourceId)
|
||||
const onResourceEventRef = useRef(options?.onResourceEvent)
|
||||
onResourceEventRef.current = options?.onResourceEvent
|
||||
const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH)
|
||||
@@ -845,7 +847,12 @@ export function useChat(
|
||||
const persistedResources = history.resources.filter((r) => r.id !== 'streaming-file')
|
||||
if (persistedResources.length > 0) {
|
||||
setResources(persistedResources)
|
||||
setActiveResourceId(persistedResources[persistedResources.length - 1].id)
|
||||
const initialId = initialActiveResourceIdRef.current
|
||||
const restoredId =
|
||||
initialId && persistedResources.some((r) => r.id === initialId)
|
||||
? initialId
|
||||
: persistedResources[persistedResources.length - 1].id
|
||||
setActiveResourceId(restoredId)
|
||||
|
||||
for (const resource of persistedResources) {
|
||||
if (resource.type !== 'workflow') continue
|
||||
|
||||
@@ -19,26 +19,23 @@ import {
|
||||
ModalHeader,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { getSubscriptionAccessState } from '@/lib/billing/client'
|
||||
import { consumeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import { getProviderIdFromServiceId, type OAuthProvider } from '@/lib/oauth'
|
||||
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
|
||||
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field'
|
||||
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
|
||||
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
|
||||
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
|
||||
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
|
||||
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||
|
||||
const SYNC_INTERVALS = [
|
||||
{ label: 'Every hour', value: 60 },
|
||||
{ label: 'Every 6 hours', value: 360 },
|
||||
{ label: 'Daily', value: 1440 },
|
||||
{ label: 'Weekly', value: 10080 },
|
||||
{ label: 'Manual only', value: 0 },
|
||||
] as const
|
||||
|
||||
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
|
||||
|
||||
interface AddConnectorModalProps {
|
||||
@@ -75,6 +72,10 @@ export function AddConnectorModal({
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
|
||||
|
||||
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
|
||||
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
|
||||
|
||||
const connectorConfig = selectedType ? CONNECTOR_REGISTRY[selectedType] : null
|
||||
const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey'
|
||||
const connectorProviderId = useMemo(
|
||||
@@ -528,8 +529,13 @@ export function AddConnectorModal({
|
||||
onValueChange={(val) => setSyncInterval(Number(val))}
|
||||
>
|
||||
{SYNC_INTERVALS.map((interval) => (
|
||||
<ButtonGroupItem key={interval.value} value={String(interval.value)}>
|
||||
<ButtonGroupItem
|
||||
key={interval.value}
|
||||
value={String(interval.value)}
|
||||
disabled={interval.requiresMax && !hasMaxAccess}
|
||||
>
|
||||
{interval.label}
|
||||
{interval.requiresMax && !hasMaxAccess && <MaxBadge />}
|
||||
</ButtonGroupItem>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
|
||||
@@ -79,6 +79,8 @@ export function ConnectorSelectorField({
|
||||
options={comboboxOptions}
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
searchable
|
||||
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
|
||||
placeholder={
|
||||
!credentialId
|
||||
? 'Connect an account first'
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export const SYNC_INTERVALS = [
|
||||
{ label: 'Live', value: 5, requiresMax: true },
|
||||
{ label: 'Every hour', value: 60, requiresMax: false },
|
||||
{ label: 'Every 6 hours', value: 360, requiresMax: false },
|
||||
{ label: 'Daily', value: 1440, requiresMax: false },
|
||||
{ label: 'Weekly', value: 10080, requiresMax: false },
|
||||
{ label: 'Manual only', value: 0, requiresMax: false },
|
||||
] as const
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
ModalTabsTrigger,
|
||||
Skeleton,
|
||||
} from '@/components/emcn'
|
||||
import { getSubscriptionAccessState } from '@/lib/billing/client'
|
||||
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
|
||||
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
|
||||
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
import type { ConnectorConfig } from '@/connectors/types'
|
||||
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
|
||||
@@ -30,17 +34,10 @@ import {
|
||||
useRestoreConnectorDocument,
|
||||
useUpdateConnector,
|
||||
} from '@/hooks/queries/kb/connectors'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('EditConnectorModal')
|
||||
|
||||
const SYNC_INTERVALS = [
|
||||
{ label: 'Every hour', value: 60 },
|
||||
{ label: 'Every 6 hours', value: 360 },
|
||||
{ label: 'Daily', value: 1440 },
|
||||
{ label: 'Weekly', value: 10080 },
|
||||
{ label: 'Manual only', value: 0 },
|
||||
] as const
|
||||
|
||||
/** Keys injected by the sync engine — not user-editable */
|
||||
const INTERNAL_CONFIG_KEYS = new Set(['tagSlotMapping', 'disabledTagIds'])
|
||||
|
||||
@@ -76,6 +73,10 @@ export function EditConnectorModal({
|
||||
|
||||
const { mutate: updateConnector, isPending: isSaving } = useUpdateConnector()
|
||||
|
||||
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
|
||||
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (syncInterval !== connector.syncIntervalMinutes) return true
|
||||
for (const [key, value] of Object.entries(sourceConfig)) {
|
||||
@@ -146,6 +147,7 @@ export function EditConnectorModal({
|
||||
setSourceConfig={setSourceConfig}
|
||||
syncInterval={syncInterval}
|
||||
setSyncInterval={setSyncInterval}
|
||||
hasMaxAccess={hasMaxAccess}
|
||||
error={error}
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
@@ -184,6 +186,7 @@ interface SettingsTabProps {
|
||||
setSourceConfig: React.Dispatch<React.SetStateAction<Record<string, string>>>
|
||||
syncInterval: number
|
||||
setSyncInterval: (v: number) => void
|
||||
hasMaxAccess: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -193,6 +196,7 @@ function SettingsTab({
|
||||
setSourceConfig,
|
||||
syncInterval,
|
||||
setSyncInterval,
|
||||
hasMaxAccess,
|
||||
error,
|
||||
}: SettingsTabProps) {
|
||||
return (
|
||||
@@ -234,8 +238,13 @@ function SettingsTab({
|
||||
onValueChange={(val) => setSyncInterval(Number(val))}
|
||||
>
|
||||
{SYNC_INTERVALS.map((interval) => (
|
||||
<ButtonGroupItem key={interval.value} value={String(interval.value)}>
|
||||
<ButtonGroupItem
|
||||
key={interval.value}
|
||||
value={String(interval.value)}
|
||||
disabled={interval.requiresMax && !hasMaxAccess}
|
||||
>
|
||||
{interval.label}
|
||||
{interval.requiresMax && !hasMaxAccess && <MaxBadge />}
|
||||
</ButtonGroupItem>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function MaxBadge() {
|
||||
return (
|
||||
<span className='ml-1 shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] font-medium text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
|
||||
Max
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Button,
|
||||
@@ -18,6 +19,7 @@ import { DatePicker } from '@/components/emcn/components/date-picker/date-picker
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { hasActiveFilters } from '@/lib/logs/filters'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
@@ -179,6 +181,9 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
}: LogsToolbarProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const posthog = usePostHog()
|
||||
const posthogRef = useRef(posthog)
|
||||
posthogRef.current = posthog
|
||||
|
||||
const {
|
||||
level,
|
||||
@@ -258,8 +263,45 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
} else {
|
||||
setLevel(values.join(','))
|
||||
}
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'status',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setLevel]
|
||||
[setLevel, workspaceId]
|
||||
)
|
||||
|
||||
const handleWorkflowFilterChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setWorkflowIds(values)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'workflow',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setWorkflowIds, workspaceId]
|
||||
)
|
||||
|
||||
const handleFolderFilterChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setFolderIds(values)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'folder',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setFolderIds, workspaceId]
|
||||
)
|
||||
|
||||
const handleTriggerFilterChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setTriggers(values)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'trigger',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setTriggers, workspaceId]
|
||||
)
|
||||
|
||||
const statusDisplayLabel = useMemo(() => {
|
||||
@@ -348,9 +390,13 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
} else {
|
||||
clearDateRange()
|
||||
setTimeRange(val as typeof timeRange)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'time',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
}
|
||||
},
|
||||
[timeRange, setTimeRange, clearDateRange]
|
||||
[timeRange, setTimeRange, clearDateRange, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -360,8 +406,12 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
(start: string, end: string) => {
|
||||
setDateRange(start, end)
|
||||
setDatePickerOpen(false)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'time',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setDateRange]
|
||||
[setDateRange, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -545,7 +595,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={workflowOptions}
|
||||
multiSelect
|
||||
multiSelectValues={workflowIds}
|
||||
onMultiSelectChange={setWorkflowIds}
|
||||
onMultiSelectChange={handleWorkflowFilterChange}
|
||||
placeholder='All workflows'
|
||||
overlayContent={
|
||||
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
|
||||
@@ -580,7 +630,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={folderOptions}
|
||||
multiSelect
|
||||
multiSelectValues={folderIds}
|
||||
onMultiSelectChange={setFolderIds}
|
||||
onMultiSelectChange={handleFolderFilterChange}
|
||||
placeholder='All folders'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
@@ -605,7 +655,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={triggerOptions}
|
||||
multiSelect
|
||||
multiSelectValues={triggers}
|
||||
onMultiSelectChange={setTriggers}
|
||||
onMultiSelectChange={handleTriggerFilterChange}
|
||||
placeholder='All triggers'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
@@ -676,7 +726,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={workflowOptions}
|
||||
multiSelect
|
||||
multiSelectValues={workflowIds}
|
||||
onMultiSelectChange={setWorkflowIds}
|
||||
onMultiSelectChange={handleWorkflowFilterChange}
|
||||
placeholder='Workflow'
|
||||
overlayContent={
|
||||
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
|
||||
@@ -707,7 +757,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={folderOptions}
|
||||
multiSelect
|
||||
multiSelectValues={folderIds}
|
||||
onMultiSelectChange={setFolderIds}
|
||||
onMultiSelectChange={handleFolderFilterChange}
|
||||
placeholder='Folder'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{folderDisplayLabel}</span>
|
||||
@@ -726,7 +776,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={triggerOptions}
|
||||
multiSelect
|
||||
multiSelectValues={triggers}
|
||||
onMultiSelectChange={setTriggers}
|
||||
onMultiSelectChange={handleTriggerFilterChange}
|
||||
placeholder='Trigger'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{triggerDisplayLabel}</span>
|
||||
|
||||
@@ -62,6 +62,8 @@ const roleOptions = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
] as const
|
||||
|
||||
const roleComboOptions = roleOptions.map((option) => ({ value: option.value, label: option.label }))
|
||||
|
||||
export function IntegrationsManager() {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
@@ -1315,42 +1317,32 @@ export function IntegrationsManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
options={roleComboOptions}
|
||||
value={
|
||||
roleOptions.find((option) => option.value === member.role)?.label || ''
|
||||
}
|
||||
selectedValue={member.role}
|
||||
onChange={(value) =>
|
||||
handleChangeMemberRole(member.userId, value as WorkspaceCredentialRole)
|
||||
}
|
||||
placeholder='Role'
|
||||
disabled={
|
||||
!isSelectedAdmin || (member.role === 'admin' && adminMemberCount <= 1)
|
||||
}
|
||||
size='sm'
|
||||
/>
|
||||
{isSelectedAdmin ? (
|
||||
<>
|
||||
<Combobox
|
||||
options={roleOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
value={
|
||||
roleOptions.find((option) => option.value === member.role)?.label ||
|
||||
''
|
||||
}
|
||||
selectedValue={member.role}
|
||||
onChange={(value) =>
|
||||
handleChangeMemberRole(
|
||||
member.userId,
|
||||
value as WorkspaceCredentialRole
|
||||
)
|
||||
}
|
||||
placeholder='Role'
|
||||
disabled={member.role === 'admin' && adminMemberCount <= 1}
|
||||
size='sm'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleRemoveMember(member.userId)}
|
||||
disabled={member.role === 'admin' && adminMemberCount <= 1}
|
||||
className='w-full justify-end'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleRemoveMember(member.userId)}
|
||||
disabled={member.role === 'admin' && adminMemberCount <= 1}
|
||||
className='w-full justify-end'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Badge variant='gray-secondary'>{member.role}</Badge>
|
||||
<div />
|
||||
</>
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -1370,10 +1362,7 @@ export function IntegrationsManager() {
|
||||
size='sm'
|
||||
/>
|
||||
<Combobox
|
||||
options={roleOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
options={roleComboOptions}
|
||||
value={
|
||||
roleOptions.find((option) => option.value === memberRole)?.label || ''
|
||||
}
|
||||
|
||||
@@ -839,6 +839,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
onSendQueuedMessage={copilotSendNow}
|
||||
onEditQueuedMessage={handleCopilotEditQueuedMessage}
|
||||
userId={session?.user?.id}
|
||||
chatId={copilotResolvedChatId}
|
||||
editValue={copilotEditingInputValue}
|
||||
onEditValueConsumed={clearCopilotEditingValue}
|
||||
layout='copilot-view'
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Button,
|
||||
Combobox,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
@@ -432,7 +431,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label htmlFor='type'>Request</Label>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>Request</p>
|
||||
<Combobox
|
||||
id='type'
|
||||
options={REQUEST_TYPE_OPTIONS}
|
||||
@@ -447,7 +446,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label htmlFor='subject'>Subject</Label>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>Subject</p>
|
||||
<Input
|
||||
id='subject'
|
||||
placeholder='Brief description of your request'
|
||||
@@ -457,7 +456,7 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label htmlFor='message'>Message</Label>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>Message</p>
|
||||
<Textarea
|
||||
id='message'
|
||||
placeholder='Please provide details about your request...'
|
||||
@@ -468,7 +467,9 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Label>Attach Images (Optional)</Label>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
Attach Images (Optional)
|
||||
</p>
|
||||
<Button
|
||||
type='button'
|
||||
variant='default'
|
||||
@@ -505,7 +506,9 @@ export function HelpModal({ open, onOpenChange, workflowId, workspaceId }: HelpM
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className='space-y-2'>
|
||||
<Label>Uploaded Images</Label>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
Uploaded Images
|
||||
</p>
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Library } from '@/components/emcn'
|
||||
import { Calendar, Database, File, HelpCircle, Settings, Table } from '@/components/emcn/icons'
|
||||
import { Search } from '@/components/emcn/icons/search'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
@@ -55,11 +57,14 @@ export function SearchModal({
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
const posthog = usePostHog()
|
||||
|
||||
const routerRef = useRef(router)
|
||||
routerRef.current = router
|
||||
const onOpenChangeRef = useRef(onOpenChange)
|
||||
onOpenChangeRef.current = onOpenChange
|
||||
const posthogRef = useRef(posthog)
|
||||
posthogRef.current = posthog
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -154,6 +159,8 @@ export function SearchModal({
|
||||
}, [open])
|
||||
|
||||
const deferredSearch = useDeferredValue(search)
|
||||
const deferredSearchRef = useRef(deferredSearch)
|
||||
deferredSearchRef.current = deferredSearch
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value)
|
||||
@@ -188,59 +195,151 @@ export function SearchModal({
|
||||
detail: { type: block.type, enableTriggerMode },
|
||||
})
|
||||
)
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: type,
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[]
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleToolOperationSelect = useCallback((op: SearchToolOperationItem) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('add-block-from-toolbar', {
|
||||
detail: { type: op.blockType, presetOperation: op.operationId },
|
||||
})
|
||||
)
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
|
||||
const handleWorkflowSelect = useCallback((workflow: WorkflowItem) => {
|
||||
if (!workflow.isCurrent && workflow.href) {
|
||||
routerRef.current.push(workflow.href)
|
||||
const handleToolOperationSelect = useCallback(
|
||||
(op: SearchToolOperationItem) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } })
|
||||
new CustomEvent('add-block-from-toolbar', {
|
||||
detail: { type: op.blockType, presetOperation: op.operationId },
|
||||
})
|
||||
)
|
||||
}
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'tool_operation',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleWorkspaceSelect = useCallback((workspace: WorkspaceItem) => {
|
||||
if (!workspace.isCurrent && workspace.href) {
|
||||
routerRef.current.push(workspace.href)
|
||||
}
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
|
||||
const handleTaskSelect = useCallback((task: TaskItem) => {
|
||||
routerRef.current.push(task.href)
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
|
||||
const handlePageSelect = useCallback((page: PageItem) => {
|
||||
if (page.onClick) {
|
||||
page.onClick()
|
||||
} else if (page.href) {
|
||||
if (page.href.startsWith('http')) {
|
||||
window.open(page.href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
routerRef.current.push(page.href)
|
||||
const handleWorkflowSelect = useCallback(
|
||||
(workflow: WorkflowItem) => {
|
||||
if (!workflow.isCurrent && workflow.href) {
|
||||
routerRef.current.push(workflow.href)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } })
|
||||
)
|
||||
}
|
||||
}
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'workflow',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleDocSelect = useCallback((doc: SearchDocItem) => {
|
||||
window.open(doc.href, '_blank', 'noopener,noreferrer')
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
const handleWorkspaceSelect = useCallback(
|
||||
(workspace: WorkspaceItem) => {
|
||||
if (!workspace.isCurrent && workspace.href) {
|
||||
routerRef.current.push(workspace.href)
|
||||
}
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'workspace',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleTaskSelect = useCallback(
|
||||
(task: TaskItem) => {
|
||||
routerRef.current.push(task.href)
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'task',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleTableSelect = useCallback(
|
||||
(item: TaskItem) => {
|
||||
routerRef.current.push(item.href)
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'table',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(item: TaskItem) => {
|
||||
routerRef.current.push(item.href)
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'file',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleKbSelect = useCallback(
|
||||
(item: TaskItem) => {
|
||||
routerRef.current.push(item.href)
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'knowledge_base',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handlePageSelect = useCallback(
|
||||
(page: PageItem) => {
|
||||
if (page.onClick) {
|
||||
page.onClick()
|
||||
} else if (page.href) {
|
||||
if (page.href.startsWith('http')) {
|
||||
window.open(page.href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
routerRef.current.push(page.href)
|
||||
}
|
||||
}
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'page',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleDocSelect = useCallback(
|
||||
(doc: SearchDocItem) => {
|
||||
window.open(doc.href, '_blank', 'noopener,noreferrer')
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'docs',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleBlockSelectAsBlock = useCallback(
|
||||
(block: SearchBlockItem) => handleBlockSelect(block, 'block'),
|
||||
@@ -370,9 +469,9 @@ export function SearchModal({
|
||||
<TriggersGroup items={filteredTriggers} onSelect={handleBlockSelectAsTrigger} />
|
||||
<WorkflowsGroup items={filteredWorkflows} onSelect={handleWorkflowSelect} />
|
||||
<TasksGroup items={filteredTasks} onSelect={handleTaskSelect} />
|
||||
<TablesGroup items={filteredTables} onSelect={handleTaskSelect} />
|
||||
<FilesGroup items={filteredFiles} onSelect={handleTaskSelect} />
|
||||
<KnowledgeBasesGroup items={filteredKnowledgeBases} onSelect={handleTaskSelect} />
|
||||
<TablesGroup items={filteredTables} onSelect={handleTableSelect} />
|
||||
<FilesGroup items={filteredFiles} onSelect={handleFileSelect} />
|
||||
<KnowledgeBasesGroup items={filteredKnowledgeBases} onSelect={handleKbSelect} />
|
||||
<ToolOpsGroup items={filteredToolOps} onSelect={handleToolOperationSelect} />
|
||||
<WorkspacesGroup items={filteredWorkspaces} onSelect={handleWorkspaceSelect} />
|
||||
<DocsGroup items={filteredDocs} onSelect={handleDocSelect} />
|
||||
|
||||
@@ -316,6 +316,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollContentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const posthog = usePostHog()
|
||||
const { data: sessionData, isPending: sessionLoading } = useSession()
|
||||
@@ -894,6 +895,9 @@ export const Sidebar = memo(function Sidebar() {
|
||||
container.addEventListener('scroll', updateScrollState, { passive: true })
|
||||
const observer = new ResizeObserver(updateScrollState)
|
||||
observer.observe(container)
|
||||
if (scrollContentRef.current) {
|
||||
observer.observe(scrollContentRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateScrollState)
|
||||
@@ -1336,275 +1340,286 @@ export const Sidebar = memo(function Sidebar() {
|
||||
!hasOverflowTop && 'border-transparent'
|
||||
)}
|
||||
>
|
||||
<div className='tasks-section flex flex-shrink-0 flex-col' data-tour='nav-tasks'>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
|
||||
{!isCollapsed && (
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
onClick={handleNewTask}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<Tooltip.Shortcut keys={isMac ? '⌘⇧K' : 'Ctrl+Shift+K'}>
|
||||
New task
|
||||
</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<div ref={scrollContentRef} className='flex flex-col'>
|
||||
<div
|
||||
className='tasks-section flex flex-shrink-0 flex-col'
|
||||
data-tour='nav-tasks'
|
||||
>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>
|
||||
All tasks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={tasksCollapsedIcon}
|
||||
hover={tasksHover}
|
||||
ariaLabel='Tasks'
|
||||
className='mt-1.5'
|
||||
primaryAction={tasksPrimaryAction}
|
||||
>
|
||||
{tasksLoading ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<Loader className='h-[14px] w-[14px]' animate />
|
||||
Loading...
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<CollapsedTaskFlyoutItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isCurrentRoute={task.id !== 'new' && pathname === task.href}
|
||||
isMenuOpen={menuOpenTaskId === task.id}
|
||||
isEditing={task.id === taskFlyoutRename.editingId}
|
||||
editValue={taskFlyoutRename.value}
|
||||
inputRef={taskFlyoutRename.inputRef}
|
||||
isRenaming={taskFlyoutRename.isSaving}
|
||||
onEditValueChange={taskFlyoutRename.setValue}
|
||||
onEditKeyDown={taskFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleTaskRenameBlur}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CollapsedSidebarMenu>
|
||||
) : (
|
||||
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
|
||||
{tasksLoading ? (
|
||||
<SidebarItemSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{tasks.slice(0, visibleTaskCount).map((task) => {
|
||||
const isCurrentRoute = task.id !== 'new' && pathname === task.href
|
||||
const isRenaming = taskFlyoutRename.editingId === task.id
|
||||
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg bg-[var(--surface-active)] px-2 text-sm'
|
||||
>
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
ref={taskFlyoutRename.inputRef}
|
||||
value={taskFlyoutRename.value}
|
||||
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
|
||||
onKeyDown={taskFlyoutRename.handleKeyDown}
|
||||
onBlur={handleTaskRenameBlur}
|
||||
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarTaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isCurrentRoute={isCurrentRoute}
|
||||
isSelected={isSelected}
|
||||
isActive={!!task.isActive}
|
||||
isUnread={!!task.isUnread}
|
||||
isMenuOpen={menuOpenTaskId === task.id}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
onMultiSelectClick={handleTaskClick}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{tasks.length > visibleTaskCount && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSeeMoreTasks}
|
||||
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
|
||||
<span className='font-base'>See more</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='workflows-section relative mt-3.5 flex flex-col'
|
||||
data-tour='nav-workflows'
|
||||
>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>Workflows</div>
|
||||
{!isCollapsed && (
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<DropdownMenu>
|
||||
{!isCollapsed && (
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{isImporting || isCreatingFolder ? (
|
||||
<Loader className='h-[16px] w-[16px]' animate />
|
||||
) : (
|
||||
<MoreHorizontal className='h-[16px] w-[16px]' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
onClick={handleNewTask}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>More actions</p>
|
||||
<Tooltip.Shortcut keys={isMac ? '⌘⇧K' : 'Ctrl+Shift+K'}>
|
||||
New task
|
||||
</Tooltip.Shortcut>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
sideOffset={8}
|
||||
className='min-w-[160px]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleImportWorkflow}
|
||||
disabled={!canEdit || isImporting}
|
||||
>
|
||||
<Download />
|
||||
{isImporting ? 'Importing...' : 'Import workflow'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleCreateFolder}
|
||||
disabled={!canEdit || isCreatingFolder}
|
||||
>
|
||||
<FolderPlus />
|
||||
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isCreatingWorkflow || !canEdit}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{isCreatingWorkflow ? (
|
||||
<p>Creating workflow...</p>
|
||||
) : (
|
||||
<Tooltip.Shortcut keys={isMac ? '⌘⇧P' : 'Ctrl+Shift+P'}>
|
||||
New workflow
|
||||
</Tooltip.Shortcut>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={tasksCollapsedIcon}
|
||||
hover={tasksHover}
|
||||
ariaLabel='Tasks'
|
||||
className='mt-1.5'
|
||||
primaryAction={tasksPrimaryAction}
|
||||
>
|
||||
{tasksLoading ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<Loader className='h-[14px] w-[14px]' animate />
|
||||
Loading...
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<CollapsedTaskFlyoutItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isCurrentRoute={task.id !== 'new' && pathname === task.href}
|
||||
isMenuOpen={menuOpenTaskId === task.id}
|
||||
isEditing={task.id === taskFlyoutRename.editingId}
|
||||
editValue={taskFlyoutRename.value}
|
||||
inputRef={taskFlyoutRename.inputRef}
|
||||
isRenaming={taskFlyoutRename.isSaving}
|
||||
onEditValueChange={taskFlyoutRename.setValue}
|
||||
onEditKeyDown={taskFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleTaskRenameBlur}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CollapsedSidebarMenu>
|
||||
) : (
|
||||
<div className='mt-1.5 flex flex-col gap-0.5 px-2'>
|
||||
{tasksLoading ? (
|
||||
<SidebarItemSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{tasks.slice(0, visibleTaskCount).map((task) => {
|
||||
const isCurrentRoute = task.id !== 'new' && pathname === task.href
|
||||
const isRenaming = taskFlyoutRename.editingId === task.id
|
||||
const isSelected = task.id !== 'new' && selectedTasks.has(task.id)
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg bg-[var(--surface-active)] px-2 text-sm'
|
||||
>
|
||||
<Blimp className='h-[16px] w-[16px] flex-shrink-0 text-[var(--text-icon)]' />
|
||||
<input
|
||||
ref={taskFlyoutRename.inputRef}
|
||||
value={taskFlyoutRename.value}
|
||||
onChange={(e) => taskFlyoutRename.setValue(e.target.value)}
|
||||
onKeyDown={taskFlyoutRename.handleKeyDown}
|
||||
onBlur={handleTaskRenameBlur}
|
||||
className='min-w-0 flex-1 border-none bg-transparent font-base text-[14px] text-[var(--text-body)] outline-none'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarTaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
isCurrentRoute={isCurrentRoute}
|
||||
isSelected={isSelected}
|
||||
isActive={!!task.isActive}
|
||||
isUnread={!!task.isUnread}
|
||||
isMenuOpen={menuOpenTaskId === task.id}
|
||||
showCollapsedTooltips={showCollapsedTooltips}
|
||||
onMultiSelectClick={handleTaskClick}
|
||||
onContextMenu={handleTaskContextMenu}
|
||||
onMorePointerDown={handleTaskMorePointerDown}
|
||||
onMoreClick={handleTaskMoreClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{tasks.length > visibleTaskCount && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSeeMoreTasks}
|
||||
className='mx-0.5 flex h-[30px] items-center gap-2 rounded-lg px-2 text-[var(--text-icon)] text-sm hover-hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
<MoreHorizontal className='h-[16px] w-[16px] flex-shrink-0' />
|
||||
<span className='font-base'>See more</span>
|
||||
</button>
|
||||
)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={workflowsCollapsedIcon}
|
||||
hover={workflowsHover}
|
||||
ariaLabel='Workflows'
|
||||
className='mt-1.5'
|
||||
primaryAction={workflowsPrimaryAction}
|
||||
>
|
||||
{workflowsLoading && regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<Loader className='h-[14px] w-[14px]' animate />
|
||||
Loading...
|
||||
</DropdownMenuItem>
|
||||
) : regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<CollapsedFolderItems
|
||||
nodes={folderTree}
|
||||
workflowsByFolder={workflowsByFolder}
|
||||
workspaceId={workspaceId}
|
||||
currentWorkflowId={workflowId}
|
||||
editingWorkflowId={workflowFlyoutRename.editingId}
|
||||
editingValue={workflowFlyoutRename.value}
|
||||
editInputRef={workflowFlyoutRename.inputRef}
|
||||
isRenamingWorkflow={workflowFlyoutRename.isSaving}
|
||||
onEditValueChange={workflowFlyoutRename.setValue}
|
||||
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleWorkflowRenameBlur}
|
||||
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
|
||||
onWorkflowRename={handleCollapsedWorkflowRename}
|
||||
canRenameWorkflow={canEdit}
|
||||
/>
|
||||
{(workflowsByFolder.root || []).map((workflow) => (
|
||||
<CollapsedWorkflowFlyoutItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
href={`/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
isCurrentRoute={workflow.id === workflowId}
|
||||
isEditing={workflow.id === workflowFlyoutRename.editingId}
|
||||
editValue={workflowFlyoutRename.value}
|
||||
inputRef={workflowFlyoutRename.inputRef}
|
||||
isRenaming={workflowFlyoutRename.isSaving}
|
||||
|
||||
<div
|
||||
className='workflows-section relative mt-3.5 flex flex-col'
|
||||
data-tour='nav-workflows'
|
||||
>
|
||||
<div className='flex h-[18px] flex-shrink-0 items-center justify-between px-4'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>
|
||||
Workflows
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<DropdownMenu>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
disabled={!canEdit}
|
||||
>
|
||||
{isImporting || isCreatingFolder ? (
|
||||
<Loader className='h-[16px] w-[16px]' animate />
|
||||
) : (
|
||||
<MoreHorizontal className='h-[16px] w-[16px]' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>More actions</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<DropdownMenuContent
|
||||
align='start'
|
||||
sideOffset={8}
|
||||
className='min-w-[160px]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleImportWorkflow}
|
||||
disabled={!canEdit || isImporting}
|
||||
>
|
||||
<Download />
|
||||
{isImporting ? 'Importing...' : 'Import workflow'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleCreateFolder}
|
||||
disabled={!canEdit || isCreatingFolder}
|
||||
>
|
||||
<FolderPlus />
|
||||
{isCreatingFolder ? 'Creating folder...' : 'Create folder'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-sm p-0 hover-hover:bg-[var(--surface-hover)]'
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isCreatingWorkflow || !canEdit}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{isCreatingWorkflow ? (
|
||||
<p>Creating workflow...</p>
|
||||
) : (
|
||||
<Tooltip.Shortcut keys={isMac ? '⌘⇧P' : 'Ctrl+Shift+P'}>
|
||||
New workflow
|
||||
</Tooltip.Shortcut>
|
||||
)}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<CollapsedSidebarMenu
|
||||
icon={workflowsCollapsedIcon}
|
||||
hover={workflowsHover}
|
||||
ariaLabel='Workflows'
|
||||
className='mt-1.5'
|
||||
primaryAction={workflowsPrimaryAction}
|
||||
>
|
||||
{workflowsLoading && regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<Loader className='h-[14px] w-[14px]' animate />
|
||||
Loading...
|
||||
</DropdownMenuItem>
|
||||
) : regularWorkflows.length === 0 ? (
|
||||
<DropdownMenuItem disabled>No workflows yet</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<CollapsedFolderItems
|
||||
nodes={folderTree}
|
||||
workflowsByFolder={workflowsByFolder}
|
||||
workspaceId={workspaceId}
|
||||
currentWorkflowId={workflowId}
|
||||
editingWorkflowId={workflowFlyoutRename.editingId}
|
||||
editingValue={workflowFlyoutRename.value}
|
||||
editInputRef={workflowFlyoutRename.inputRef}
|
||||
isRenamingWorkflow={workflowFlyoutRename.isSaving}
|
||||
onEditValueChange={workflowFlyoutRename.setValue}
|
||||
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleWorkflowRenameBlur}
|
||||
onOpenInNewTab={() => handleCollapsedWorkflowOpenInNewTab(workflow)}
|
||||
onRename={() => handleCollapsedWorkflowRename(workflow)}
|
||||
canRename={canEdit}
|
||||
onWorkflowOpenInNewTab={handleCollapsedWorkflowOpenInNewTab}
|
||||
onWorkflowRename={handleCollapsedWorkflowRename}
|
||||
canRenameWorkflow={canEdit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CollapsedSidebarMenu>
|
||||
) : (
|
||||
<div className='mt-1.5 px-2'>
|
||||
{workflowsLoading && regularWorkflows.length === 0 && (
|
||||
<SidebarItemSkeleton />
|
||||
)}
|
||||
<WorkflowList
|
||||
workspaceId={workspaceId}
|
||||
workflowId={workflowId}
|
||||
regularWorkflows={regularWorkflows}
|
||||
isLoading={isLoading}
|
||||
canReorder={canEdit}
|
||||
handleFileChange={handleImportFileChange}
|
||||
fileInputRef={fileInputRef}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(workflowsByFolder.root || []).map((workflow) => (
|
||||
<CollapsedWorkflowFlyoutItem
|
||||
key={workflow.id}
|
||||
workflow={workflow}
|
||||
href={`/workspace/${workspaceId}/w/${workflow.id}`}
|
||||
isCurrentRoute={workflow.id === workflowId}
|
||||
isEditing={workflow.id === workflowFlyoutRename.editingId}
|
||||
editValue={workflowFlyoutRename.value}
|
||||
inputRef={workflowFlyoutRename.inputRef}
|
||||
isRenaming={workflowFlyoutRename.isSaving}
|
||||
onEditValueChange={workflowFlyoutRename.setValue}
|
||||
onEditKeyDown={workflowFlyoutRename.handleKeyDown}
|
||||
onEditBlur={handleWorkflowRenameBlur}
|
||||
onOpenInNewTab={() =>
|
||||
handleCollapsedWorkflowOpenInNewTab(workflow)
|
||||
}
|
||||
onRename={() => handleCollapsedWorkflowRename(workflow)}
|
||||
canRename={canEdit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CollapsedSidebarMenu>
|
||||
) : (
|
||||
<div className='mt-1.5 px-2'>
|
||||
{workflowsLoading && regularWorkflows.length === 0 && (
|
||||
<SidebarItemSkeleton />
|
||||
)}
|
||||
<WorkflowList
|
||||
workspaceId={workspaceId}
|
||||
workflowId={workflowId}
|
||||
regularWorkflows={regularWorkflows}
|
||||
isLoading={isLoading}
|
||||
canReorder={canEdit}
|
||||
handleFileChange={handleImportFileChange}
|
||||
fileInputRef={fileInputRef}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
onCreateWorkflow={handleCreateWorkflow}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
disableCreate={!canEdit || isCreatingWorkflow || isCreatingFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import {
|
||||
downloadFile,
|
||||
exportWorkflowsToZip,
|
||||
@@ -27,6 +29,7 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string | undefined
|
||||
const posthog = usePostHog()
|
||||
|
||||
const onSuccessRef = useRef(onSuccess)
|
||||
onSuccessRef.current = onSuccess
|
||||
@@ -34,6 +37,9 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
|
||||
const workspaceIdRef = useRef(workspaceId)
|
||||
workspaceIdRef.current = workspaceId
|
||||
|
||||
const posthogRef = useRef(posthog)
|
||||
posthogRef.current = posthog
|
||||
|
||||
/**
|
||||
* Export the workflow(s) to JSON or ZIP
|
||||
* - Single workflow: exports as JSON file
|
||||
@@ -100,6 +106,12 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
captureEvent(posthogRef.current, 'workflow_exported', {
|
||||
workspace_id: workspaceIdRef.current ?? '',
|
||||
workflow_count: exportedWorkflows.length,
|
||||
format: exportedWorkflows.length === 1 ? 'json' : 'zip',
|
||||
})
|
||||
|
||||
logger.info('Workflow(s) exported successfully', {
|
||||
workflowIds: workflowIdsToExport,
|
||||
count: exportedWorkflows.length,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import {
|
||||
extractWorkflowsFromFiles,
|
||||
extractWorkflowsFromZip,
|
||||
@@ -36,6 +38,9 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const createFolderMutation = useCreateFolder()
|
||||
const clearDiff = useWorkflowDiffStore((state) => state.clearDiff)
|
||||
const posthog = usePostHog()
|
||||
const posthogRef = useRef(posthog)
|
||||
posthogRef.current = posthog
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
|
||||
/**
|
||||
@@ -204,6 +209,11 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`)
|
||||
|
||||
if (importedWorkflowIds.length > 0) {
|
||||
captureEvent(posthogRef.current, 'workflow_imported', {
|
||||
workspace_id: workspaceId,
|
||||
workflow_count: importedWorkflowIds.length,
|
||||
format: hasZip && fileArray.length === 1 ? 'zip' : 'json',
|
||||
})
|
||||
router.push(
|
||||
`/workspace/${workspaceId}/w/${importedWorkflowIds[importedWorkflowIds.length - 1]}`
|
||||
)
|
||||
|
||||
329
apps/sim/blocks/blocks/cloudformation.ts
Normal file
329
apps/sim/blocks/blocks/cloudformation.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { CloudFormationIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { IntegrationType } from '@/blocks/types'
|
||||
import type {
|
||||
CloudFormationDescribeStackDriftDetectionStatusResponse,
|
||||
CloudFormationDescribeStackEventsResponse,
|
||||
CloudFormationDescribeStacksResponse,
|
||||
CloudFormationDetectStackDriftResponse,
|
||||
CloudFormationGetTemplateResponse,
|
||||
CloudFormationListStackResourcesResponse,
|
||||
CloudFormationValidateTemplateResponse,
|
||||
} from '@/tools/cloudformation/types'
|
||||
|
||||
export const CloudFormationBlock: BlockConfig<
|
||||
| CloudFormationDescribeStacksResponse
|
||||
| CloudFormationListStackResourcesResponse
|
||||
| CloudFormationDetectStackDriftResponse
|
||||
| CloudFormationDescribeStackDriftDetectionStatusResponse
|
||||
| CloudFormationDescribeStackEventsResponse
|
||||
| CloudFormationGetTemplateResponse
|
||||
| CloudFormationValidateTemplateResponse
|
||||
> = {
|
||||
type: 'cloudformation',
|
||||
name: 'CloudFormation',
|
||||
description: 'Manage and inspect AWS CloudFormation stacks, resources, and drift',
|
||||
longDescription:
|
||||
'Integrate AWS CloudFormation into workflows. Describe stacks, list resources, detect drift, view stack events, retrieve templates, and validate templates. Requires AWS access key and secret access key.',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.DeveloperTools,
|
||||
tags: ['cloud'],
|
||||
docsLink: 'https://docs.sim.ai/tools/cloudformation',
|
||||
bgColor: 'linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)',
|
||||
icon: CloudFormationIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Describe Stacks', id: 'describe_stacks' },
|
||||
{ label: 'List Stack Resources', id: 'list_stack_resources' },
|
||||
{ label: 'Describe Stack Events', id: 'describe_stack_events' },
|
||||
{ label: 'Detect Stack Drift', id: 'detect_stack_drift' },
|
||||
{ label: 'Drift Detection Status', id: 'describe_stack_drift_detection_status' },
|
||||
{ label: 'Get Template', id: 'get_template' },
|
||||
{ label: 'Validate Template', id: 'validate_template' },
|
||||
],
|
||||
value: () => 'describe_stacks',
|
||||
},
|
||||
{
|
||||
id: 'awsRegion',
|
||||
title: 'AWS Region',
|
||||
type: 'short-input',
|
||||
placeholder: 'us-east-1',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'awsAccessKeyId',
|
||||
title: 'AWS Access Key ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'AKIA...',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'awsSecretAccessKey',
|
||||
title: 'AWS Secret Access Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Your secret access key',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'stackName',
|
||||
title: 'Stack Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'my-stack or arn:aws:cloudformation:...',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'describe_stacks',
|
||||
'list_stack_resources',
|
||||
'describe_stack_events',
|
||||
'detect_stack_drift',
|
||||
'get_template',
|
||||
],
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'list_stack_resources',
|
||||
'describe_stack_events',
|
||||
'detect_stack_drift',
|
||||
'get_template',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'stackDriftDetectionId',
|
||||
title: 'Drift Detection ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'ID from Detect Stack Drift output',
|
||||
condition: { field: 'operation', value: 'describe_stack_drift_detection_status' },
|
||||
required: { field: 'operation', value: 'describe_stack_drift_detection_status' },
|
||||
},
|
||||
{
|
||||
id: 'templateBody',
|
||||
title: 'Template Body',
|
||||
type: 'code',
|
||||
placeholder: '{\n "AWSTemplateFormatVersion": "2010-09-09",\n "Resources": { ... }\n}',
|
||||
condition: { field: 'operation', value: 'validate_template' },
|
||||
required: { field: 'operation', value: 'validate_template' },
|
||||
},
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: '50',
|
||||
condition: { field: 'operation', value: 'describe_stack_events' },
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'cloudformation_describe_stacks',
|
||||
'cloudformation_list_stack_resources',
|
||||
'cloudformation_detect_stack_drift',
|
||||
'cloudformation_describe_stack_drift_detection_status',
|
||||
'cloudformation_describe_stack_events',
|
||||
'cloudformation_get_template',
|
||||
'cloudformation_validate_template',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'describe_stacks':
|
||||
return 'cloudformation_describe_stacks'
|
||||
case 'list_stack_resources':
|
||||
return 'cloudformation_list_stack_resources'
|
||||
case 'detect_stack_drift':
|
||||
return 'cloudformation_detect_stack_drift'
|
||||
case 'describe_stack_drift_detection_status':
|
||||
return 'cloudformation_describe_stack_drift_detection_status'
|
||||
case 'describe_stack_events':
|
||||
return 'cloudformation_describe_stack_events'
|
||||
case 'get_template':
|
||||
return 'cloudformation_get_template'
|
||||
case 'validate_template':
|
||||
return 'cloudformation_validate_template'
|
||||
default:
|
||||
throw new Error(`Invalid CloudFormation operation: ${params.operation}`)
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { operation, limit, ...rest } = params
|
||||
|
||||
const awsRegion = rest.awsRegion
|
||||
const awsAccessKeyId = rest.awsAccessKeyId
|
||||
const awsSecretAccessKey = rest.awsSecretAccessKey
|
||||
const parsedLimit = limit ? Number.parseInt(String(limit), 10) : undefined
|
||||
|
||||
switch (operation) {
|
||||
case 'describe_stacks':
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
...(rest.stackName && { stackName: rest.stackName }),
|
||||
}
|
||||
|
||||
case 'list_stack_resources': {
|
||||
if (!rest.stackName) {
|
||||
throw new Error('Stack name is required')
|
||||
}
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
stackName: rest.stackName,
|
||||
}
|
||||
}
|
||||
|
||||
case 'detect_stack_drift': {
|
||||
if (!rest.stackName) {
|
||||
throw new Error('Stack name is required')
|
||||
}
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
stackName: rest.stackName,
|
||||
}
|
||||
}
|
||||
|
||||
case 'describe_stack_drift_detection_status': {
|
||||
if (!rest.stackDriftDetectionId) {
|
||||
throw new Error('Drift detection ID is required')
|
||||
}
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
stackDriftDetectionId: rest.stackDriftDetectionId,
|
||||
}
|
||||
}
|
||||
|
||||
case 'describe_stack_events': {
|
||||
if (!rest.stackName) {
|
||||
throw new Error('Stack name is required')
|
||||
}
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
stackName: rest.stackName,
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
}
|
||||
|
||||
case 'get_template': {
|
||||
if (!rest.stackName) {
|
||||
throw new Error('Stack name is required')
|
||||
}
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
stackName: rest.stackName,
|
||||
}
|
||||
}
|
||||
|
||||
case 'validate_template': {
|
||||
if (!rest.templateBody) {
|
||||
throw new Error('Template body is required')
|
||||
}
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
templateBody: rest.templateBody,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Invalid CloudFormation operation: ${operation}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'CloudFormation operation to perform' },
|
||||
awsRegion: { type: 'string', description: 'AWS region' },
|
||||
awsAccessKeyId: { type: 'string', description: 'AWS access key ID' },
|
||||
awsSecretAccessKey: { type: 'string', description: 'AWS secret access key' },
|
||||
stackName: { type: 'string', description: 'Stack name or ID' },
|
||||
stackDriftDetectionId: { type: 'string', description: 'Drift detection ID' },
|
||||
templateBody: { type: 'string', description: 'CloudFormation template body (JSON or YAML)' },
|
||||
limit: { type: 'number', description: 'Maximum number of results' },
|
||||
},
|
||||
outputs: {
|
||||
stacks: {
|
||||
type: 'array',
|
||||
description: 'List of CloudFormation stacks with status, outputs, and tags',
|
||||
},
|
||||
resources: {
|
||||
type: 'array',
|
||||
description: 'List of stack resources with type, status, and drift info',
|
||||
},
|
||||
events: {
|
||||
type: 'array',
|
||||
description: 'Stack events with resource status and timestamps',
|
||||
},
|
||||
stackDriftDetectionId: {
|
||||
type: 'string',
|
||||
description: 'Drift detection ID for checking status',
|
||||
},
|
||||
stackId: {
|
||||
type: 'string',
|
||||
description: 'Stack ID',
|
||||
},
|
||||
stackDriftStatus: {
|
||||
type: 'string',
|
||||
description: 'Drift status (DRIFTED, IN_SYNC, NOT_CHECKED)',
|
||||
},
|
||||
detectionStatus: {
|
||||
type: 'string',
|
||||
description: 'Detection status (DETECTION_IN_PROGRESS, DETECTION_COMPLETE, DETECTION_FAILED)',
|
||||
},
|
||||
detectionStatusReason: {
|
||||
type: 'string',
|
||||
description: 'Reason if detection failed',
|
||||
},
|
||||
driftedStackResourceCount: {
|
||||
type: 'number',
|
||||
description: 'Number of drifted resources',
|
||||
},
|
||||
timestamp: {
|
||||
type: 'number',
|
||||
description: 'Detection timestamp',
|
||||
},
|
||||
templateBody: {
|
||||
type: 'string',
|
||||
description: 'Template body (JSON or YAML)',
|
||||
},
|
||||
stagesAvailable: {
|
||||
type: 'array',
|
||||
description: 'Available template stages',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Template description',
|
||||
},
|
||||
parameters: {
|
||||
type: 'array',
|
||||
description: 'Template parameters',
|
||||
},
|
||||
capabilities: {
|
||||
type: 'array',
|
||||
description: 'Required capabilities',
|
||||
},
|
||||
capabilitiesReason: {
|
||||
type: 'string',
|
||||
description: 'Reason capabilities are required',
|
||||
},
|
||||
declaredTransforms: {
|
||||
type: 'array',
|
||||
description: 'Transforms used in the template (e.g., AWS::Serverless-2016-10-31)',
|
||||
},
|
||||
},
|
||||
}
|
||||
571
apps/sim/blocks/blocks/cloudwatch.ts
Normal file
571
apps/sim/blocks/blocks/cloudwatch.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import { CloudWatchIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { IntegrationType } from '@/blocks/types'
|
||||
import type {
|
||||
CloudWatchDescribeAlarmsResponse,
|
||||
CloudWatchDescribeLogGroupsResponse,
|
||||
CloudWatchDescribeLogStreamsResponse,
|
||||
CloudWatchGetLogEventsResponse,
|
||||
CloudWatchGetMetricStatisticsResponse,
|
||||
CloudWatchListMetricsResponse,
|
||||
CloudWatchQueryLogsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
|
||||
export const CloudWatchBlock: BlockConfig<
|
||||
| CloudWatchQueryLogsResponse
|
||||
| CloudWatchDescribeLogGroupsResponse
|
||||
| CloudWatchDescribeLogStreamsResponse
|
||||
| CloudWatchGetLogEventsResponse
|
||||
| CloudWatchDescribeAlarmsResponse
|
||||
| CloudWatchListMetricsResponse
|
||||
| CloudWatchGetMetricStatisticsResponse
|
||||
> = {
|
||||
type: 'cloudwatch',
|
||||
name: 'CloudWatch',
|
||||
description: 'Query and monitor AWS CloudWatch logs, metrics, and alarms',
|
||||
longDescription:
|
||||
'Integrate AWS CloudWatch into workflows. Run Log Insights queries, list log groups, retrieve log events, list and get metrics, and monitor alarms. Requires AWS access key and secret access key.',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.Analytics,
|
||||
tags: ['cloud', 'monitoring'],
|
||||
bgColor: 'linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)',
|
||||
icon: CloudWatchIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Query Logs (Insights)', id: 'query_logs' },
|
||||
{ label: 'Describe Log Groups', id: 'describe_log_groups' },
|
||||
{ label: 'Get Log Events', id: 'get_log_events' },
|
||||
{ label: 'Describe Log Streams', id: 'describe_log_streams' },
|
||||
{ label: 'List Metrics', id: 'list_metrics' },
|
||||
{ label: 'Get Metric Statistics', id: 'get_metric_statistics' },
|
||||
{ label: 'Describe Alarms', id: 'describe_alarms' },
|
||||
],
|
||||
value: () => 'query_logs',
|
||||
},
|
||||
{
|
||||
id: 'awsRegion',
|
||||
title: 'AWS Region',
|
||||
type: 'short-input',
|
||||
placeholder: 'us-east-1',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'awsAccessKeyId',
|
||||
title: 'AWS Access Key ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'AKIA...',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'awsSecretAccessKey',
|
||||
title: 'AWS Secret Access Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Your secret access key',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
// Query Logs fields
|
||||
{
|
||||
id: 'logGroupSelector',
|
||||
title: 'Log Group',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'logGroupNames',
|
||||
selectorKey: 'cloudwatch.logGroups',
|
||||
dependsOn: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'],
|
||||
placeholder: 'Select a log group',
|
||||
condition: { field: 'operation', value: 'query_logs' },
|
||||
required: { field: 'operation', value: 'query_logs' },
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'logGroupNamesInput',
|
||||
title: 'Log Group Names',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'logGroupNames',
|
||||
placeholder: '/aws/lambda/my-func, /aws/ecs/my-service',
|
||||
condition: { field: 'operation', value: 'query_logs' },
|
||||
required: { field: 'operation', value: 'query_logs' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'queryString',
|
||||
title: 'Query',
|
||||
type: 'code',
|
||||
placeholder: 'fields @timestamp, @message\n| sort @timestamp desc\n| limit 20',
|
||||
condition: { field: 'operation', value: 'query_logs' },
|
||||
required: { field: 'operation', value: 'query_logs' },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a CloudWatch Log Insights query based on the user's description.
|
||||
The query language supports: fields, filter, stats, sort, limit, parse, display.
|
||||
Common patterns:
|
||||
- fields @timestamp, @message | sort @timestamp desc | limit 20
|
||||
- filter @message like /ERROR/ | stats count(*) by bin(1h)
|
||||
- stats avg(duration) as avgDuration by functionName | sort avgDuration desc
|
||||
- filter @message like /Exception/ | parse @message "* Exception: *" as prefix, errorMsg
|
||||
- stats count(*) as requestCount by status | sort requestCount desc
|
||||
|
||||
Return ONLY the query — no explanations, no markdown code blocks.`,
|
||||
placeholder: 'Describe what you want to find in the logs...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'startTime',
|
||||
title: 'Start Time (Unix epoch seconds)',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., 1711900800',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['query_logs', 'get_log_events', 'get_metric_statistics'],
|
||||
},
|
||||
required: { field: 'operation', value: ['query_logs', 'get_metric_statistics'] },
|
||||
},
|
||||
{
|
||||
id: 'endTime',
|
||||
title: 'End Time (Unix epoch seconds)',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., 1711987200',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['query_logs', 'get_log_events', 'get_metric_statistics'],
|
||||
},
|
||||
required: { field: 'operation', value: ['query_logs', 'get_metric_statistics'] },
|
||||
},
|
||||
// Describe Log Groups fields
|
||||
{
|
||||
id: 'prefix',
|
||||
title: 'Log Group Name Prefix',
|
||||
type: 'short-input',
|
||||
placeholder: '/aws/lambda/',
|
||||
condition: { field: 'operation', value: 'describe_log_groups' },
|
||||
},
|
||||
// Get Log Events / Describe Log Streams — shared log group selector
|
||||
{
|
||||
id: 'logGroupNameSelector',
|
||||
title: 'Log Group',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'logGroupName',
|
||||
selectorKey: 'cloudwatch.logGroups',
|
||||
dependsOn: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'],
|
||||
placeholder: 'Select a log group',
|
||||
condition: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
|
||||
required: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'logGroupNameInput',
|
||||
title: 'Log Group Name',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'logGroupName',
|
||||
placeholder: '/aws/lambda/my-func',
|
||||
condition: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
|
||||
required: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Describe Log Streams — stream prefix filter
|
||||
{
|
||||
id: 'streamPrefix',
|
||||
title: 'Stream Name Prefix',
|
||||
type: 'short-input',
|
||||
placeholder: '2024/03/31/',
|
||||
condition: { field: 'operation', value: 'describe_log_streams' },
|
||||
},
|
||||
// Get Log Events — log stream selector (cascading: depends on log group)
|
||||
{
|
||||
id: 'logStreamNameSelector',
|
||||
title: 'Log Stream',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'logStreamName',
|
||||
selectorKey: 'cloudwatch.logStreams',
|
||||
dependsOn: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion', 'logGroupNameSelector'],
|
||||
placeholder: 'Select a log stream',
|
||||
condition: { field: 'operation', value: 'get_log_events' },
|
||||
required: { field: 'operation', value: 'get_log_events' },
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'logStreamNameInput',
|
||||
title: 'Log Stream Name',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'logStreamName',
|
||||
placeholder: '2024/03/31/[$LATEST]abc123',
|
||||
condition: { field: 'operation', value: 'get_log_events' },
|
||||
required: { field: 'operation', value: 'get_log_events' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// List Metrics fields
|
||||
{
|
||||
id: 'metricNamespace',
|
||||
title: 'Namespace',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., AWS/EC2, AWS/Lambda, AWS/RDS',
|
||||
condition: { field: 'operation', value: ['list_metrics', 'get_metric_statistics'] },
|
||||
required: { field: 'operation', value: 'get_metric_statistics' },
|
||||
},
|
||||
{
|
||||
id: 'metricName',
|
||||
title: 'Metric Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., CPUUtilization, Invocations',
|
||||
condition: { field: 'operation', value: ['list_metrics', 'get_metric_statistics'] },
|
||||
required: { field: 'operation', value: 'get_metric_statistics' },
|
||||
},
|
||||
{
|
||||
id: 'recentlyActive',
|
||||
title: 'Recently Active Only',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'list_metrics' },
|
||||
},
|
||||
// Get Metric Statistics fields
|
||||
{
|
||||
id: 'metricPeriod',
|
||||
title: 'Period (seconds)',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., 60, 300, 3600',
|
||||
condition: { field: 'operation', value: 'get_metric_statistics' },
|
||||
required: { field: 'operation', value: 'get_metric_statistics' },
|
||||
},
|
||||
{
|
||||
id: 'metricStatistics',
|
||||
title: 'Statistics',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Average', id: 'Average' },
|
||||
{ label: 'Sum', id: 'Sum' },
|
||||
{ label: 'Minimum', id: 'Minimum' },
|
||||
{ label: 'Maximum', id: 'Maximum' },
|
||||
{ label: 'Sample Count', id: 'SampleCount' },
|
||||
],
|
||||
condition: { field: 'operation', value: 'get_metric_statistics' },
|
||||
required: { field: 'operation', value: 'get_metric_statistics' },
|
||||
},
|
||||
{
|
||||
id: 'metricDimensions',
|
||||
title: 'Dimensions',
|
||||
type: 'table',
|
||||
columns: ['name', 'value'],
|
||||
condition: { field: 'operation', value: 'get_metric_statistics' },
|
||||
},
|
||||
// Describe Alarms fields
|
||||
{
|
||||
id: 'alarmNamePrefix',
|
||||
title: 'Alarm Name Prefix',
|
||||
type: 'short-input',
|
||||
placeholder: 'my-service-',
|
||||
condition: { field: 'operation', value: 'describe_alarms' },
|
||||
},
|
||||
{
|
||||
id: 'stateValue',
|
||||
title: 'State',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All States', id: '' },
|
||||
{ label: 'OK', id: 'OK' },
|
||||
{ label: 'ALARM', id: 'ALARM' },
|
||||
{ label: 'INSUFFICIENT_DATA', id: 'INSUFFICIENT_DATA' },
|
||||
],
|
||||
condition: { field: 'operation', value: 'describe_alarms' },
|
||||
},
|
||||
{
|
||||
id: 'alarmType',
|
||||
title: 'Alarm Type',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All Types', id: '' },
|
||||
{ label: 'Metric Alarm', id: 'MetricAlarm' },
|
||||
{ label: 'Composite Alarm', id: 'CompositeAlarm' },
|
||||
],
|
||||
condition: { field: 'operation', value: 'describe_alarms' },
|
||||
},
|
||||
// Shared limit field
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: '100',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'query_logs',
|
||||
'describe_log_groups',
|
||||
'get_log_events',
|
||||
'describe_log_streams',
|
||||
'list_metrics',
|
||||
'describe_alarms',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'cloudwatch_query_logs',
|
||||
'cloudwatch_describe_log_groups',
|
||||
'cloudwatch_get_log_events',
|
||||
'cloudwatch_describe_log_streams',
|
||||
'cloudwatch_list_metrics',
|
||||
'cloudwatch_get_metric_statistics',
|
||||
'cloudwatch_describe_alarms',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'query_logs':
|
||||
return 'cloudwatch_query_logs'
|
||||
case 'describe_log_groups':
|
||||
return 'cloudwatch_describe_log_groups'
|
||||
case 'get_log_events':
|
||||
return 'cloudwatch_get_log_events'
|
||||
case 'describe_log_streams':
|
||||
return 'cloudwatch_describe_log_streams'
|
||||
case 'list_metrics':
|
||||
return 'cloudwatch_list_metrics'
|
||||
case 'get_metric_statistics':
|
||||
return 'cloudwatch_get_metric_statistics'
|
||||
case 'describe_alarms':
|
||||
return 'cloudwatch_describe_alarms'
|
||||
default:
|
||||
throw new Error(`Invalid CloudWatch operation: ${params.operation}`)
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { operation, startTime, endTime, limit, ...rest } = params
|
||||
|
||||
const awsRegion = rest.awsRegion
|
||||
const awsAccessKeyId = rest.awsAccessKeyId
|
||||
const awsSecretAccessKey = rest.awsSecretAccessKey
|
||||
const parsedLimit = limit ? Number.parseInt(String(limit), 10) : undefined
|
||||
|
||||
switch (operation) {
|
||||
case 'query_logs': {
|
||||
const logGroupNames = rest.logGroupNames
|
||||
if (!logGroupNames) {
|
||||
throw new Error('Log group names are required')
|
||||
}
|
||||
if (!startTime) {
|
||||
throw new Error('Start time is required')
|
||||
}
|
||||
if (!endTime) {
|
||||
throw new Error('End time is required')
|
||||
}
|
||||
|
||||
const groupNames =
|
||||
typeof logGroupNames === 'string'
|
||||
? logGroupNames
|
||||
.split(',')
|
||||
.map((n: string) => n.trim())
|
||||
.filter(Boolean)
|
||||
: logGroupNames
|
||||
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
logGroupNames: groupNames,
|
||||
queryString: rest.queryString,
|
||||
startTime: Number(startTime),
|
||||
endTime: Number(endTime),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
}
|
||||
|
||||
case 'describe_log_groups':
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
...(rest.prefix && { prefix: rest.prefix }),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
|
||||
case 'get_log_events': {
|
||||
if (!rest.logGroupName) {
|
||||
throw new Error('Log group name is required')
|
||||
}
|
||||
if (!rest.logStreamName) {
|
||||
throw new Error('Log stream name is required')
|
||||
}
|
||||
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
logGroupName: rest.logGroupName,
|
||||
logStreamName: rest.logStreamName,
|
||||
...(startTime && { startTime: Number(startTime) }),
|
||||
...(endTime && { endTime: Number(endTime) }),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
}
|
||||
|
||||
case 'describe_log_streams': {
|
||||
if (!rest.logGroupName) {
|
||||
throw new Error('Log group name is required')
|
||||
}
|
||||
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
logGroupName: rest.logGroupName,
|
||||
...(rest.streamPrefix && { prefix: rest.streamPrefix }),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
}
|
||||
|
||||
case 'list_metrics':
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
...(rest.metricNamespace && { namespace: rest.metricNamespace }),
|
||||
...(rest.metricName && { metricName: rest.metricName }),
|
||||
...(rest.recentlyActive && { recentlyActive: true }),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
|
||||
case 'get_metric_statistics': {
|
||||
if (!rest.metricNamespace) {
|
||||
throw new Error('Namespace is required')
|
||||
}
|
||||
if (!rest.metricName) {
|
||||
throw new Error('Metric name is required')
|
||||
}
|
||||
if (!startTime) {
|
||||
throw new Error('Start time is required')
|
||||
}
|
||||
if (!endTime) {
|
||||
throw new Error('End time is required')
|
||||
}
|
||||
if (!rest.metricPeriod) {
|
||||
throw new Error('Period is required')
|
||||
}
|
||||
|
||||
const stat = rest.metricStatistics
|
||||
if (!stat) {
|
||||
throw new Error('Statistics is required')
|
||||
}
|
||||
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
namespace: rest.metricNamespace,
|
||||
metricName: rest.metricName,
|
||||
startTime: Number(startTime),
|
||||
endTime: Number(endTime),
|
||||
period: Number(rest.metricPeriod),
|
||||
statistics: Array.isArray(stat) ? stat : [stat],
|
||||
...(rest.metricDimensions && {
|
||||
dimensions: (() => {
|
||||
const dims = rest.metricDimensions
|
||||
if (typeof dims === 'string') return dims
|
||||
if (Array.isArray(dims)) {
|
||||
const obj: Record<string, string> = {}
|
||||
for (const row of dims) {
|
||||
const name = row.cells?.name
|
||||
const value = row.cells?.value
|
||||
if (name && value !== undefined) obj[name] = String(value)
|
||||
}
|
||||
return JSON.stringify(obj)
|
||||
}
|
||||
return JSON.stringify(dims)
|
||||
})(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
case 'describe_alarms':
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
...(rest.alarmNamePrefix && { alarmNamePrefix: rest.alarmNamePrefix }),
|
||||
...(rest.stateValue && { stateValue: rest.stateValue }),
|
||||
...(rest.alarmType && { alarmType: rest.alarmType }),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Invalid CloudWatch operation: ${operation}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'CloudWatch operation to perform' },
|
||||
awsRegion: { type: 'string', description: 'AWS region' },
|
||||
awsAccessKeyId: { type: 'string', description: 'AWS access key ID' },
|
||||
awsSecretAccessKey: { type: 'string', description: 'AWS secret access key' },
|
||||
logGroupNames: { type: 'string', description: 'Log group name(s) for query' },
|
||||
queryString: { type: 'string', description: 'CloudWatch Log Insights query string' },
|
||||
startTime: { type: 'string', description: 'Start time as Unix epoch seconds' },
|
||||
endTime: { type: 'string', description: 'End time as Unix epoch seconds' },
|
||||
prefix: { type: 'string', description: 'Log group name prefix filter' },
|
||||
logGroupName: {
|
||||
type: 'string',
|
||||
description: 'Log group name for get events / describe streams',
|
||||
},
|
||||
logStreamName: { type: 'string', description: 'Log stream name for get events' },
|
||||
streamPrefix: { type: 'string', description: 'Log stream name prefix filter' },
|
||||
metricNamespace: { type: 'string', description: 'Metric namespace (e.g., AWS/EC2)' },
|
||||
metricName: { type: 'string', description: 'Metric name (e.g., CPUUtilization)' },
|
||||
recentlyActive: { type: 'boolean', description: 'Only show recently active metrics' },
|
||||
metricPeriod: { type: 'number', description: 'Granularity in seconds' },
|
||||
metricStatistics: { type: 'string', description: 'Statistic type (Average, Sum, etc.)' },
|
||||
metricDimensions: { type: 'json', description: 'Metric dimensions (Name/Value pairs)' },
|
||||
alarmNamePrefix: { type: 'string', description: 'Alarm name prefix filter' },
|
||||
stateValue: {
|
||||
type: 'string',
|
||||
description: 'Alarm state filter (OK, ALARM, INSUFFICIENT_DATA)',
|
||||
},
|
||||
alarmType: { type: 'string', description: 'Alarm type filter (MetricAlarm, CompositeAlarm)' },
|
||||
limit: { type: 'number', description: 'Maximum number of results' },
|
||||
},
|
||||
outputs: {
|
||||
results: {
|
||||
type: 'array',
|
||||
description: 'Log Insights query result rows',
|
||||
},
|
||||
statistics: {
|
||||
type: 'json',
|
||||
description: 'Query statistics (bytesScanned, recordsMatched, recordsScanned)',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Query completion status',
|
||||
},
|
||||
logGroups: {
|
||||
type: 'array',
|
||||
description: 'List of CloudWatch log groups',
|
||||
},
|
||||
events: {
|
||||
type: 'array',
|
||||
description: 'Log events with timestamp and message',
|
||||
},
|
||||
logStreams: {
|
||||
type: 'array',
|
||||
description: 'Log streams with metadata',
|
||||
},
|
||||
metrics: {
|
||||
type: 'array',
|
||||
description: 'List of available metrics',
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'Metric label',
|
||||
},
|
||||
datapoints: {
|
||||
type: 'array',
|
||||
description: 'Metric datapoints with timestamps and values',
|
||||
},
|
||||
alarms: {
|
||||
type: 'array',
|
||||
description: 'CloudWatch alarms with state and configuration',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import { CirclebackBlock } from '@/blocks/blocks/circleback'
|
||||
import { ClayBlock } from '@/blocks/blocks/clay'
|
||||
import { ClerkBlock } from '@/blocks/blocks/clerk'
|
||||
import { CloudflareBlock } from '@/blocks/blocks/cloudflare'
|
||||
import { CloudFormationBlock } from '@/blocks/blocks/cloudformation'
|
||||
import { CloudWatchBlock } from '@/blocks/blocks/cloudwatch'
|
||||
import { ConditionBlock } from '@/blocks/blocks/condition'
|
||||
import { ConfluenceBlock, ConfluenceV2Block } from '@/blocks/blocks/confluence'
|
||||
import { CredentialBlock } from '@/blocks/blocks/credential'
|
||||
@@ -241,6 +243,8 @@ export const registry: Record<string, BlockConfig> = {
|
||||
chat_trigger: ChatTriggerBlock,
|
||||
circleback: CirclebackBlock,
|
||||
cloudflare: CloudflareBlock,
|
||||
cloudformation: CloudFormationBlock,
|
||||
cloudwatch: CloudWatchBlock,
|
||||
clay: ClayBlock,
|
||||
clerk: ClerkBlock,
|
||||
condition: ConditionBlock,
|
||||
|
||||
@@ -72,6 +72,8 @@ export { Table } from './table'
|
||||
export { TableX } from './table-x'
|
||||
export { TagIcon } from './tag'
|
||||
export { TerminalWindow } from './terminal-window'
|
||||
export { ThumbsDown } from './thumbs-down'
|
||||
export { ThumbsUp } from './thumbs-up'
|
||||
export { Trash } from './trash'
|
||||
export { TrashOutline } from './trash-outline'
|
||||
export { Trash2 } from './trash2'
|
||||
|
||||
28
apps/sim/components/emcn/icons/thumbs-down.tsx
Normal file
28
apps/sim/components/emcn/icons/thumbs-down.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* ThumbsDown icon component
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function ThumbsDown(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='-1 -2 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.75'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
aria-hidden='true'
|
||||
{...props}
|
||||
>
|
||||
<g transform='scale(1,-1) translate(0,-20)'>
|
||||
<path d='M6 8v12' />
|
||||
<path d='M14 3.88L13 8h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 16.5 20H3a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L11 0a3.13 3.13 0 0 1 3 3.88Z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
26
apps/sim/components/emcn/icons/thumbs-up.tsx
Normal file
26
apps/sim/components/emcn/icons/thumbs-up.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
/**
|
||||
* ThumbsUp icon component
|
||||
* @param props - SVG properties including className, fill, etc.
|
||||
*/
|
||||
export function ThumbsUp(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width='24'
|
||||
height='24'
|
||||
viewBox='-1 -2 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='1.75'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
aria-hidden='true'
|
||||
{...props}
|
||||
>
|
||||
<path d='M6 8v12' />
|
||||
<path d='M14 3.88L13 8h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 16.5 20H3a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L11 0a3.13 3.13 0 0 1 3 3.88Z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -4653,6 +4653,59 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function CloudFormationIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 80 80'
|
||||
version='1.1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
xmlnsXlink='http://www.w3.org/1999/xlink'
|
||||
>
|
||||
<g
|
||||
id='Icon-Architecture/64/Arch_AWS-CloudFormation_64'
|
||||
stroke='none'
|
||||
strokeWidth='1'
|
||||
fill='none'
|
||||
fillRule='evenodd'
|
||||
>
|
||||
<path
|
||||
d='M53,39.9632039 L58,39.9632039 L58,37.9601375 L53,37.9601375 L53,39.9632039 Z M28,51.9816019 L33,51.9816019 L33,49.9785356 L28,49.9785356 L28,51.9816019 Z M18,51.9816019 L25,51.9816019 L25,49.9785356 L18,49.9785356 L18,51.9816019 Z M18,45.9724029 L30,45.9724029 L30,43.9693366 L18,43.9693366 L18,45.9724029 Z M18,33.9540048 L27,33.9540048 L27,31.9509385 L18,31.9509385 L18,33.9540048 Z M18,39.9632039 L51,39.9632039 L51,37.9601375 L18,37.9601375 L18,39.9632039 Z M37,61.9969337 L14,61.9969337 L14,27.9448058 L37,27.9448058 L37,35.9570712 L39,35.9570712 L39,26.9432726 C39,26.3904263 38.552,25.9417395 38,25.9417395 L13,25.9417395 C12.447,25.9417395 12,26.3904263 12,26.9432726 L12,62.9984668 C12,63.5513131 12.447,64 13,64 L38,64 C38.552,64 39,63.5513131 39,62.9984668 L39,42.9678034 L37,42.9678034 L37,61.9969337 Z M68,36.9586044 C68,43.4305117 62.173,45.6819583 59.092,45.9683968 L43,45.9724029 L43,43.9693366 L59,43.9693366 C59.195,43.9463013 66,43.2121775 66,36.9586044 C66,31.2638867 60.863,30.1081175 59.834,29.9338507 C59.321,29.8467173 58.96,29.3820059 59.004,28.8632117 C59.005,28.8441826 59.007,28.826155 59.009,28.8081274 C58.954,25.5902013 56.981,24.584662 56.126,24.3002266 C54.53,23.769414 52.751,24.2771913 51.81,25.5391231 C51.591,25.8355769 51.229,25.9868085 50.861,25.9307226 C50.497,25.8756383 50.192,25.625255 50.068,25.2767214 C49.447,23.5360568 48.546,22.4083304 47.293,21.1534094 C44.159,18.0386412 39.905,17.1783242 35.925,18.8528877 C33.837,19.7332353 32.012,21.7282894 30.922,24.327268 L29.078,23.5500782 C30.37,20.4743699 32.584,18.0887179 35.15,17.007062 C39.905,15.0049972 44.971,16.0255595 48.704,19.7342369 C49.774,20.8068789 50.66,21.851478 51.35,23.2035478 C52.843,22.0978551 54.857,21.7673492 56.757,22.3993166 C59.189,23.2085554 60.727,25.3207889 60.975,28.1290879 C64.381,28.9884034 68,31.7115721 68,36.9586044 L68,36.9586044 Z'
|
||||
id='AWS-CloudFormation_Icon_64_Squid'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CloudWatchIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 80 80'
|
||||
version='1.1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
xmlnsXlink='http://www.w3.org/1999/xlink'
|
||||
>
|
||||
<g
|
||||
id='Icon-Architecture/64/Arch_Amazon-CloudWatch_64'
|
||||
stroke='none'
|
||||
strokeWidth='1'
|
||||
fill='none'
|
||||
fillRule='evenodd'
|
||||
transform='translate(40, 40) scale(1.25) translate(-40, -40)'
|
||||
>
|
||||
<path
|
||||
d='M55.0592315,46.7773707 C55.0592315,42.8680281 51.8575588,39.6876305 47.9220646,39.6876305 C43.9865705,39.6876305 40.785903,42.8680281 40.785903,46.7773707 C40.785903,50.6867133 43.9865705,53.8671108 47.9220646,53.8671108 C51.8575588,53.8671108 55.0592315,50.6867133 55.0592315,46.7773707 M57.0697011,46.7773707 C57.0697011,51.7881194 52.9663327,55.8642207 47.9220646,55.8642207 C42.8788018,55.8642207 38.7754334,51.7881194 38.7754334,46.7773707 C38.7754334,41.7666219 42.8788018,37.6905206 47.9220646,37.6905206 C52.9663327,37.6905206 57.0697011,41.7666219 57.0697011,46.7773707 M65.5096522,60.4735504 L58.5011554,54.2026253 C57.9352082,54.9944794 57.2808004,55.7174332 56.5540156,56.3634982 L63.5524601,62.6334248 C64.1495696,63.1686502 65.0784065,63.1187225 65.6182176,62.5255808 C66.155013,61.9324392 66.1067617,61.010773 65.5096522,60.4735504 M47.9220646,57.6616197 C53.9645309,57.6616197 58.8801289,52.7786859 58.8801289,46.7773707 C58.8801289,40.7750569 53.9645309,35.8931217 47.9220646,35.8931217 C41.8806036,35.8931217 36.9650056,40.7750569 36.9650056,46.7773707 C36.9650056,52.7786859 41.8806036,57.6616197 47.9220646,57.6616197 M67.1119965,63.8626459 C66.4264264,64.6165549 65.47849,65 64.5285431,65 C63.7002296,65 62.8699057,64.708422 62.207456,64.1172774 L54.9305615,57.5987107 C52.9070239,58.8968321 50.505518,59.6587296 47.9220646,59.6587296 C40.7718297,59.6587296 34.9545361,53.8800921 34.9545361,46.7773707 C34.9545361,39.6746493 40.7718297,33.8960118 47.9220646,33.8960118 C55.0733048,33.8960118 60.8905985,39.6746493 60.8905985,46.7773707 C60.8905985,48.8154213 60.3990387,50.7366411 59.5465996,52.4511599 L66.8556616,58.9906963 C68.2750531,60.265851 68.3896499,62.4496907 67.1119965,63.8626459 M21.2803274,29.392529 C21.2803274,29.9117776 21.3124949,30.429029 21.3738143,30.9293051 C21.4089975,31.2138932 21.3205368,31.4984814 21.1295422,31.7131707 C20.9777518,31.8839236 20.7736891,31.9967603 20.550527,32.0347054 C18.0786547,32.6687878 14.0104695,34.5880104 14.0104695,40.3456782 C14.0104695,44.6933865 16.4240382,47.0929141 18.4495863,48.3411077 C19.1411878,48.7744806 19.9594489,49.0051468 20.8229456,49.0141338 L32.9450717,49.0251179 L32.9430613,51.0222278 L20.811888,51.0112437 C19.5664021,50.9982625 18.384246,50.6607509 17.3840374,50.0346569 C15.3765836,48.7974474 12,45.8896553 12,40.3456782 C12,33.66235 16.5999543,31.191925 19.3000149,30.319188 C19.2799102,30.0116331 19.2698579,29.702081 19.2698579,29.392529 C19.2698579,23.9324305 22.9982737,18.2696254 27.9420183,16.2215892 C33.7241287,13.8150717 39.8500294,15.0083449 44.3263399,19.4109737 C45.7135638,20.7749998 46.8545053,22.4316024 47.7300648,24.3478294 C48.9061895,23.3802296 50.355738,22.8460027 51.8836949,22.8460027 C54.8863312,22.8460027 58.2659305,25.1097268 58.8680661,30.0605622 C61.6797078,30.7046302 67.6206453,32.9553731 67.6206453,40.422567 C67.6206453,43.4042521 66.6797455,45.8666886 64.8230769,47.7419748 L63.3896121,46.3410022 C64.8632863,44.8531553 65.6101757,42.8620367 65.6101757,40.422567 C65.6101757,33.891019 60.1055101,32.2663701 57.737177,31.8719409 C57.4677741,31.827006 57.2295334,31.6752256 57.0757325,31.4515493 C56.9259525,31.2358614 56.8686541,30.9712444 56.9138897,30.7146157 C56.5851779,26.6604826 54.1605516,24.8431126 51.8836949,24.8431126 C50.4472144,24.8431126 49.1001998,25.5381069 48.1874466,26.7503526 C47.9652897,27.0439277 47.6044105,27.193711 47.2344841,27.139789 C46.8695838,27.085867 46.5629872,26.8362283 46.4373329,26.4917268 C45.6140456,24.2260057 44.4278686,22.3207628 42.9119745,20.8309188 C39.0327735,17.0154404 33.7281496,15.9809374 28.7170543,18.0649216 C24.5463352,19.7924217 21.2803274,24.7672224 21.2803274,29.392529'
|
||||
id='Amazon-CloudWatch_Icon_64_Squid'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -119,7 +119,7 @@ const ISSUE_FIELDS = `
|
||||
`
|
||||
|
||||
const ISSUE_BY_ID_QUERY = `
|
||||
query GetIssue($id: String!) {
|
||||
query GetIssue($id: ID!) {
|
||||
issue(id: $id) {
|
||||
${ISSUE_FIELDS}
|
||||
}
|
||||
@@ -147,13 +147,13 @@ function buildIssuesQuery(sourceConfig: Record<string, unknown>): {
|
||||
const variables: Record<string, unknown> = {}
|
||||
|
||||
if (teamId) {
|
||||
varDefs.push('$teamId: String!')
|
||||
varDefs.push('$teamId: ID!')
|
||||
filterClauses.push('team: { id: { eq: $teamId } }')
|
||||
variables.teamId = teamId
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
varDefs.push('$projectId: String!')
|
||||
varDefs.push('$projectId: ID!')
|
||||
filterClauses.push('project: { id: { eq: $projectId } }')
|
||||
variables.projectId = projectId
|
||||
}
|
||||
|
||||
39
apps/sim/hooks/queries/copilot-feedback.ts
Normal file
39
apps/sim/hooks/queries/copilot-feedback.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
|
||||
const logger = createLogger('CopilotFeedbackMutation')
|
||||
|
||||
interface SubmitFeedbackVariables {
|
||||
chatId: string
|
||||
userQuery: string
|
||||
agentResponse: string
|
||||
isPositiveFeedback: boolean
|
||||
feedback?: string
|
||||
}
|
||||
|
||||
interface SubmitFeedbackResponse {
|
||||
success: boolean
|
||||
feedbackId: string
|
||||
}
|
||||
|
||||
export function useSubmitCopilotFeedback() {
|
||||
return useMutation({
|
||||
mutationFn: async (variables: SubmitFeedbackVariables): Promise<SubmitFeedbackResponse> => {
|
||||
const response = await fetch('/api/copilot/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(variables),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error || 'Failed to submit feedback')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to submit copilot feedback:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1716,6 +1716,81 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
}))
|
||||
},
|
||||
},
|
||||
'cloudwatch.logGroups': {
|
||||
key: 'cloudwatch.logGroups',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'cloudwatch.logGroups',
|
||||
context.awsAccessKeyId ?? 'none',
|
||||
context.awsRegion ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) =>
|
||||
Boolean(context.awsAccessKeyId && context.awsSecretAccessKey && context.awsRegion),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const body = JSON.stringify({
|
||||
accessKeyId: context.awsAccessKeyId,
|
||||
secretAccessKey: context.awsSecretAccessKey,
|
||||
region: context.awsRegion,
|
||||
...(search && { prefix: search }),
|
||||
})
|
||||
const data = await fetchJson<{
|
||||
output: { logGroups: { logGroupName: string }[] }
|
||||
}>('/api/tools/cloudwatch/describe-log-groups', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
return (data.output?.logGroups || []).map((lg) => ({
|
||||
id: lg.logGroupName,
|
||||
label: lg.logGroupName,
|
||||
}))
|
||||
},
|
||||
fetchById: async ({ detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId) return null
|
||||
return { id: detailId, label: detailId }
|
||||
},
|
||||
},
|
||||
'cloudwatch.logStreams': {
|
||||
key: 'cloudwatch.logStreams',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'cloudwatch.logStreams',
|
||||
context.awsAccessKeyId ?? 'none',
|
||||
context.awsRegion ?? 'none',
|
||||
context.logGroupName ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) =>
|
||||
Boolean(
|
||||
context.awsAccessKeyId &&
|
||||
context.awsSecretAccessKey &&
|
||||
context.awsRegion &&
|
||||
context.logGroupName
|
||||
),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const body = JSON.stringify({
|
||||
accessKeyId: context.awsAccessKeyId,
|
||||
secretAccessKey: context.awsSecretAccessKey,
|
||||
region: context.awsRegion,
|
||||
logGroupName: context.logGroupName,
|
||||
...(search && { prefix: search }),
|
||||
})
|
||||
const data = await fetchJson<{
|
||||
output: { logStreams: { logStreamName: string; lastEventTimestamp?: number }[] }
|
||||
}>('/api/tools/cloudwatch/describe-log-streams', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
return (data.output?.logStreams || []).map((ls) => ({
|
||||
id: ls.logStreamName,
|
||||
label: ls.logStreamName,
|
||||
}))
|
||||
},
|
||||
fetchById: async ({ detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId) return null
|
||||
return { id: detailId, label: detailId }
|
||||
},
|
||||
},
|
||||
'sim.workflows': {
|
||||
key: 'sim.workflows',
|
||||
staleTime: SELECTOR_STALE,
|
||||
|
||||
@@ -49,6 +49,8 @@ export type SelectorKey =
|
||||
| 'webflow.sites'
|
||||
| 'webflow.collections'
|
||||
| 'webflow.items'
|
||||
| 'cloudwatch.logGroups'
|
||||
| 'cloudwatch.logStreams'
|
||||
| 'sim.workflows'
|
||||
|
||||
export interface SelectorOption {
|
||||
@@ -78,6 +80,10 @@ export interface SelectorContext {
|
||||
datasetId?: string
|
||||
serviceDeskId?: string
|
||||
impersonateUserEmail?: string
|
||||
awsAccessKeyId?: string
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
logGroupName?: string
|
||||
}
|
||||
|
||||
export interface SelectorQueryArgs {
|
||||
|
||||
@@ -140,25 +140,17 @@ async function initializeOpenTelemetry() {
|
||||
|
||||
sdk.start()
|
||||
|
||||
const shutdownHandler = async () => {
|
||||
const shutdownOtel = async () => {
|
||||
try {
|
||||
await sdk.shutdown()
|
||||
logger.info('OpenTelemetry SDK shut down successfully')
|
||||
} catch (err) {
|
||||
logger.error('Error shutting down OpenTelemetry SDK', err)
|
||||
}
|
||||
|
||||
try {
|
||||
const { getPostHogClient } = await import('@/lib/posthog/server')
|
||||
await getPostHogClient()?.shutdown()
|
||||
logger.info('PostHog client shut down successfully')
|
||||
} catch (err) {
|
||||
logger.error('Error shutting down PostHog client', err)
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdownHandler)
|
||||
process.on('SIGINT', shutdownHandler)
|
||||
process.on('SIGTERM', shutdownOtel)
|
||||
process.on('SIGINT', shutdownOtel)
|
||||
|
||||
logger.info('OpenTelemetry instrumentation initialized with business span filtering')
|
||||
} catch (error) {
|
||||
@@ -169,6 +161,19 @@ async function initializeOpenTelemetry() {
|
||||
export async function register() {
|
||||
await initializeOpenTelemetry()
|
||||
|
||||
const shutdownPostHog = async () => {
|
||||
try {
|
||||
const { getPostHogClient } = await import('@/lib/posthog/server')
|
||||
await getPostHogClient()?.shutdown()
|
||||
logger.info('PostHog client shut down successfully')
|
||||
} catch (err) {
|
||||
logger.error('Error shutting down PostHog client', err)
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdownPostHog)
|
||||
process.on('SIGINT', shutdownPostHog)
|
||||
|
||||
const { startMemoryTelemetry } = await import('./lib/monitoring/memory-telemetry')
|
||||
startMemoryTelemetry()
|
||||
}
|
||||
|
||||
@@ -448,9 +448,11 @@ export async function hasInboxAccess(userId: string): Promise<boolean> {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
const sub = await getHighestPrioritySubscription(userId)
|
||||
const [sub, billingStatus] = await Promise.all([
|
||||
getHighestPrioritySubscription(userId),
|
||||
getEffectiveBillingStatus(userId),
|
||||
])
|
||||
if (!sub) return false
|
||||
const billingStatus = await getEffectiveBillingStatus(userId)
|
||||
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) return false
|
||||
return getPlanTierCredits(sub.plan) >= 25000 || checkEnterprisePlan(sub)
|
||||
} catch (error) {
|
||||
@@ -459,6 +461,30 @@ export async function hasInboxAccess(userId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to live sync (every 5 minutes) for KB connectors
|
||||
* Returns true if:
|
||||
* - Self-hosted deployment, OR
|
||||
* - User has a Max plan (credits >= 25000) or enterprise plan
|
||||
*/
|
||||
export async function hasLiveSyncAccess(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isHosted) {
|
||||
return true
|
||||
}
|
||||
const [sub, billingStatus] = await Promise.all([
|
||||
getHighestPrioritySubscription(userId),
|
||||
getEffectiveBillingStatus(userId),
|
||||
])
|
||||
if (!sub) return false
|
||||
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) return false
|
||||
return getPlanTierCredits(sub.plan) >= 25000 || checkEnterprisePlan(sub)
|
||||
} catch (error) {
|
||||
logger.error('Error checking live sync access', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has exceeded their cost limit based on current period usage
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ export type UsageLogSource =
|
||||
| 'workspace-chat'
|
||||
| 'mcp_copilot'
|
||||
| 'mothership_block'
|
||||
| 'knowledge-base'
|
||||
|
||||
/**
|
||||
* Metadata for 'model' category charges
|
||||
|
||||
@@ -81,7 +81,8 @@ export class DocsChunker {
|
||||
const textChunks = await this.splitContent(markdownContent)
|
||||
|
||||
logger.info(`Generating embeddings for ${textChunks.length} chunks in ${relativePath}`)
|
||||
const embeddings = textChunks.length > 0 ? await generateEmbeddings(textChunks) : []
|
||||
const embeddings: number[][] =
|
||||
textChunks.length > 0 ? (await generateEmbeddings(textChunks)).embeddings : []
|
||||
const embeddingModel = 'text-embedding-3-small'
|
||||
|
||||
const chunks: DocChunk[] = []
|
||||
|
||||
@@ -40,6 +40,7 @@ export const buildTimeCSPDirectives: CSPDirectives = {
|
||||
'https://*.google.com',
|
||||
'https://apis.google.com',
|
||||
'https://assets.onedollarstats.com',
|
||||
'https://challenges.cloudflare.com',
|
||||
...(isReactGrabEnabled ? ['https://unpkg.com'] : []),
|
||||
],
|
||||
|
||||
@@ -102,6 +103,7 @@ export const buildTimeCSPDirectives: CSPDirectives = {
|
||||
'https://*.supabase.co',
|
||||
'https://api.github.com',
|
||||
'https://github.com/*',
|
||||
'https://challenges.cloudflare.com',
|
||||
'https://collector.onedollarstats.com',
|
||||
...getHostnameFromUrl(env.NEXT_PUBLIC_BRAND_LOGO_URL),
|
||||
...getHostnameFromUrl(env.NEXT_PUBLIC_PRIVACY_URL),
|
||||
@@ -110,6 +112,7 @@ export const buildTimeCSPDirectives: CSPDirectives = {
|
||||
|
||||
'frame-src': [
|
||||
"'self'",
|
||||
'https://challenges.cloudflare.com',
|
||||
'https://drive.google.com',
|
||||
'https://docs.google.com',
|
||||
'https://*.google.com',
|
||||
@@ -171,13 +174,13 @@ export function generateRuntimeCSP(): string {
|
||||
|
||||
return `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com https://assets.onedollarstats.com ${reactGrabScript};
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.google.com https://apis.google.com https://assets.onedollarstats.com https://challenges.cloudflare.com ${reactGrabScript};
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||
img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com https://*.s3.amazonaws.com https://s3.amazonaws.com https://*.amazonaws.com https://*.blob.core.windows.net https://github.com/* https://collector.onedollarstats.com ${brandLogoDomain} ${brandFaviconDomain};
|
||||
media-src 'self' blob:;
|
||||
font-src 'self' https://fonts.gstatic.com;
|
||||
connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://api.browser-use.com https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://api.github.com https://github.com/* https://*.atlassian.com https://*.supabase.co https://collector.onedollarstats.com ${dynamicDomainsStr};
|
||||
frame-src 'self' https://drive.google.com https://docs.google.com https://*.google.com;
|
||||
connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://api.browser-use.com https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://api.github.com https://github.com/* https://*.atlassian.com https://*.supabase.co https://challenges.cloudflare.com https://collector.onedollarstats.com ${dynamicDomainsStr};
|
||||
frame-src 'self' https://challenges.cloudflare.com https://drive.google.com https://docs.google.com https://*.google.com;
|
||||
frame-ancestors 'self';
|
||||
form-action 'self';
|
||||
base-uri 'self';
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function createChunk(
|
||||
workspaceId?: string | null
|
||||
): Promise<ChunkData> {
|
||||
logger.info(`[${requestId}] Generating embedding for manual chunk`)
|
||||
const embeddings = await generateEmbeddings([chunkData.content], undefined, workspaceId)
|
||||
const { embeddings } = await generateEmbeddings([chunkData.content], undefined, workspaceId)
|
||||
|
||||
// Calculate accurate token count
|
||||
const tokenCount = estimateTokenCount(chunkData.content, 'openai')
|
||||
@@ -359,7 +359,7 @@ export async function updateChunk(
|
||||
if (content !== currentChunk[0].content) {
|
||||
logger.info(`[${requestId}] Content changed, regenerating embedding for chunk ${chunkId}`)
|
||||
|
||||
const embeddings = await generateEmbeddings([content], undefined, workspaceId)
|
||||
const { embeddings } = await generateEmbeddings([content], undefined, workspaceId)
|
||||
|
||||
// Calculate accurate token count
|
||||
const tokenCount = estimateTokenCount(content, 'openai')
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
type SQL,
|
||||
sql,
|
||||
} from 'drizzle-orm'
|
||||
import { recordUsage } from '@/lib/billing/core/usage-log'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch'
|
||||
import { processDocument } from '@/lib/knowledge/documents/document-processor'
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
@@ -43,6 +45,7 @@ import type { ProcessedDocumentTags } from '@/lib/knowledge/types'
|
||||
import { deleteFile } from '@/lib/uploads/core/storage-service'
|
||||
import { extractStorageKey } from '@/lib/uploads/utils/file-utils'
|
||||
import type { DocumentProcessingPayload } from '@/background/knowledge-processing'
|
||||
import { calculateCost } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('DocumentService')
|
||||
|
||||
@@ -460,6 +463,10 @@ export async function processDocumentAsync(
|
||||
overlap: rawConfig?.overlap ?? 200,
|
||||
}
|
||||
|
||||
let totalEmbeddingTokens = 0
|
||||
let embeddingIsBYOK = false
|
||||
let embeddingModelName = 'text-embedding-3-small'
|
||||
|
||||
await withTimeout(
|
||||
(async () => {
|
||||
const processed = await processDocument(
|
||||
@@ -500,10 +507,20 @@ export async function processDocumentAsync(
|
||||
const batchNum = Math.floor(i / batchSize) + 1
|
||||
|
||||
logger.info(`[${documentId}] Processing embedding batch ${batchNum}/${totalBatches}`)
|
||||
const batchEmbeddings = await generateEmbeddings(batch, undefined, kb[0].workspaceId)
|
||||
const {
|
||||
embeddings: batchEmbeddings,
|
||||
totalTokens: batchTokens,
|
||||
isBYOK,
|
||||
modelName,
|
||||
} = await generateEmbeddings(batch, undefined, kb[0].workspaceId)
|
||||
for (const emb of batchEmbeddings) {
|
||||
embeddings.push(emb)
|
||||
}
|
||||
totalEmbeddingTokens += batchTokens
|
||||
if (i === 0) {
|
||||
embeddingIsBYOK = isBYOK
|
||||
embeddingModelName = modelName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,6 +655,45 @@ export async function processDocumentAsync(
|
||||
|
||||
const processingTime = Date.now() - startTime
|
||||
logger.info(`[${documentId}] Successfully processed document in ${processingTime}ms`)
|
||||
|
||||
if (!embeddingIsBYOK && totalEmbeddingTokens > 0 && kb[0].userId) {
|
||||
try {
|
||||
const costMultiplier = getCostMultiplier()
|
||||
const { total: cost } = calculateCost(
|
||||
embeddingModelName,
|
||||
totalEmbeddingTokens,
|
||||
0,
|
||||
false,
|
||||
costMultiplier
|
||||
)
|
||||
if (cost > 0) {
|
||||
await recordUsage({
|
||||
userId: kb[0].userId,
|
||||
workspaceId: kb[0].workspaceId ?? undefined,
|
||||
entries: [
|
||||
{
|
||||
category: 'model',
|
||||
source: 'knowledge-base',
|
||||
description: embeddingModelName,
|
||||
cost,
|
||||
metadata: { inputTokens: totalEmbeddingTokens, outputTokens: 0 },
|
||||
},
|
||||
],
|
||||
additionalStats: {
|
||||
totalTokensUsed: sql`total_tokens_used + ${totalEmbeddingTokens}`,
|
||||
},
|
||||
})
|
||||
await checkAndBillOverageThreshold(kb[0].userId)
|
||||
} else {
|
||||
logger.warn(
|
||||
`[${documentId}] Embedding model "${embeddingModelName}" has no pricing entry — billing skipped`,
|
||||
{ totalEmbeddingTokens, embeddingModelName }
|
||||
)
|
||||
}
|
||||
} catch (billingError) {
|
||||
logger.error(`[${documentId}] Failed to record embedding usage`, { error: billingError })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const processingTime = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
@@ -35,6 +35,7 @@ interface EmbeddingConfig {
|
||||
apiUrl: string
|
||||
headers: Record<string, string>
|
||||
modelName: string
|
||||
isBYOK: boolean
|
||||
}
|
||||
|
||||
interface EmbeddingResponseItem {
|
||||
@@ -71,16 +72,19 @@ async function getEmbeddingConfig(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
modelName: kbModelName,
|
||||
isBYOK: false,
|
||||
}
|
||||
}
|
||||
|
||||
let openaiApiKey = env.OPENAI_API_KEY
|
||||
let isBYOK = false
|
||||
|
||||
if (workspaceId) {
|
||||
const byokResult = await getBYOKKey(workspaceId, 'openai')
|
||||
if (byokResult) {
|
||||
logger.info('Using workspace BYOK key for OpenAI embeddings')
|
||||
openaiApiKey = byokResult.apiKey
|
||||
isBYOK = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,12 +102,16 @@ async function getEmbeddingConfig(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
modelName: embeddingModel,
|
||||
isBYOK,
|
||||
}
|
||||
}
|
||||
|
||||
const EMBEDDING_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
async function callEmbeddingAPI(inputs: string[], config: EmbeddingConfig): Promise<number[][]> {
|
||||
async function callEmbeddingAPI(
|
||||
inputs: string[],
|
||||
config: EmbeddingConfig
|
||||
): Promise<{ embeddings: number[][]; totalTokens: number }> {
|
||||
return retryWithExponentialBackoff(
|
||||
async () => {
|
||||
const useDimensions = supportsCustomDimensions(config.modelName)
|
||||
@@ -140,7 +148,10 @@ async function callEmbeddingAPI(inputs: string[], config: EmbeddingConfig): Prom
|
||||
}
|
||||
|
||||
const data: EmbeddingAPIResponse = await response.json()
|
||||
return data.data.map((item) => item.embedding)
|
||||
return {
|
||||
embeddings: data.data.map((item) => item.embedding),
|
||||
totalTokens: data.usage.total_tokens,
|
||||
}
|
||||
},
|
||||
{
|
||||
maxRetries: 3,
|
||||
@@ -178,14 +189,23 @@ async function processWithConcurrency<T, R>(
|
||||
return results
|
||||
}
|
||||
|
||||
export interface GenerateEmbeddingsResult {
|
||||
embeddings: number[][]
|
||||
totalTokens: number
|
||||
isBYOK: boolean
|
||||
modelName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for multiple texts with token-aware batching and parallel processing
|
||||
* Generate embeddings for multiple texts with token-aware batching and parallel processing.
|
||||
* Returns embeddings alongside actual token count, model name, and whether a workspace BYOK key
|
||||
* was used (vs. the platform's shared key) — enabling callers to make correct billing decisions.
|
||||
*/
|
||||
export async function generateEmbeddings(
|
||||
texts: string[],
|
||||
embeddingModel = 'text-embedding-3-small',
|
||||
workspaceId?: string | null
|
||||
): Promise<number[][]> {
|
||||
): Promise<GenerateEmbeddingsResult> {
|
||||
const config = await getEmbeddingConfig(embeddingModel, workspaceId)
|
||||
|
||||
const batches = batchByTokenLimit(texts, MAX_TOKENS_PER_REQUEST, embeddingModel)
|
||||
@@ -204,13 +224,20 @@ export async function generateEmbeddings(
|
||||
)
|
||||
|
||||
const allEmbeddings: number[][] = []
|
||||
let totalTokens = 0
|
||||
for (const batch of batchResults) {
|
||||
for (const emb of batch) {
|
||||
for (const emb of batch.embeddings) {
|
||||
allEmbeddings.push(emb)
|
||||
}
|
||||
totalTokens += batch.totalTokens
|
||||
}
|
||||
|
||||
return allEmbeddings
|
||||
return {
|
||||
embeddings: allEmbeddings,
|
||||
totalTokens,
|
||||
isBYOK: config.isBYOK,
|
||||
modelName: config.modelName,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,6 +254,6 @@ export async function generateSearchEmbedding(
|
||||
`Using ${config.useAzure ? 'Azure OpenAI' : 'OpenAI'} for search embedding generation`
|
||||
)
|
||||
|
||||
const embeddings = await callEmbeddingAPI([query], config)
|
||||
const { embeddings } = await callEmbeddingAPI([query], config)
|
||||
return embeddings[0]
|
||||
}
|
||||
|
||||
@@ -358,15 +358,12 @@ export interface PostHogEventMap {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
task_marked_read: {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
task_marked_unread: {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
task_message_sent: {
|
||||
workspace_id: string
|
||||
has_attachments: boolean
|
||||
has_contexts: boolean
|
||||
is_new_task: boolean
|
||||
@@ -389,6 +386,62 @@ export interface PostHogEventMap {
|
||||
source: 'help_menu' | 'editor_button' | 'toolbar_context_menu'
|
||||
block_type?: string
|
||||
}
|
||||
|
||||
search_result_selected: {
|
||||
result_type:
|
||||
| 'block'
|
||||
| 'tool'
|
||||
| 'trigger'
|
||||
| 'tool_operation'
|
||||
| 'workflow'
|
||||
| 'workspace'
|
||||
| 'task'
|
||||
| 'table'
|
||||
| 'file'
|
||||
| 'knowledge_base'
|
||||
| 'page'
|
||||
| 'docs'
|
||||
query_length: number
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
workflow_imported: {
|
||||
workspace_id: string
|
||||
workflow_count: number
|
||||
format: 'json' | 'zip'
|
||||
}
|
||||
|
||||
workflow_exported: {
|
||||
workspace_id: string
|
||||
workflow_count: number
|
||||
format: 'json' | 'zip'
|
||||
}
|
||||
|
||||
folder_created: {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
folder_deleted: {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
logs_filter_applied: {
|
||||
filter_type: 'status' | 'workflow' | 'folder' | 'trigger' | 'time'
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
knowledge_base_document_deleted: {
|
||||
knowledge_base_id: string
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
scheduled_task_created: {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
scheduled_task_deleted: {
|
||||
workspace_id: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PostHogEventName = keyof PostHogEventMap
|
||||
|
||||
@@ -123,6 +123,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
|
||||
requestId: 'req-1',
|
||||
workflowId: 'workflow-1',
|
||||
userId: 'user-1',
|
||||
workflowUserId: 'workflow-owner',
|
||||
workspaceId: 'workspace-1',
|
||||
triggerType: 'api',
|
||||
executionId: 'execution-1',
|
||||
@@ -755,4 +756,92 @@ describe('executeWorkflowCore terminal finalization sequencing', () => {
|
||||
expect(safeCompleteWithErrorMock).not.toHaveBeenCalled()
|
||||
expect(wasExecutionFinalizedByCore(envError, 'execution-no-log-start')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses sessionUserId for env resolution when isClientSession is true', async () => {
|
||||
const snapshot = {
|
||||
...createSnapshot(),
|
||||
metadata: {
|
||||
...createSnapshot().metadata,
|
||||
isClientSession: true,
|
||||
sessionUserId: 'session-user',
|
||||
workflowUserId: 'workflow-owner',
|
||||
},
|
||||
}
|
||||
|
||||
getPersonalAndWorkspaceEnvMock.mockResolvedValue({
|
||||
personalEncrypted: {},
|
||||
workspaceEncrypted: {},
|
||||
personalDecrypted: {},
|
||||
workspaceDecrypted: {},
|
||||
})
|
||||
safeStartMock.mockResolvedValue(true)
|
||||
executorExecuteMock.mockResolvedValue({
|
||||
output: { done: true },
|
||||
logs: [],
|
||||
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
|
||||
})
|
||||
|
||||
await executeWorkflowCore({
|
||||
snapshot: snapshot as any,
|
||||
callbacks: {},
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
|
||||
expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('session-user', 'workspace-1')
|
||||
})
|
||||
|
||||
it('uses workflowUserId for env resolution in server-side execution', async () => {
|
||||
const snapshot = {
|
||||
...createSnapshot(),
|
||||
metadata: {
|
||||
...createSnapshot().metadata,
|
||||
isClientSession: false,
|
||||
sessionUserId: undefined,
|
||||
workflowUserId: 'workflow-owner',
|
||||
userId: 'billing-actor',
|
||||
},
|
||||
}
|
||||
|
||||
getPersonalAndWorkspaceEnvMock.mockResolvedValue({
|
||||
personalEncrypted: {},
|
||||
workspaceEncrypted: {},
|
||||
personalDecrypted: {},
|
||||
workspaceDecrypted: {},
|
||||
})
|
||||
safeStartMock.mockResolvedValue(true)
|
||||
executorExecuteMock.mockResolvedValue({
|
||||
output: { done: true },
|
||||
logs: [],
|
||||
metadata: { duration: 123, startTime: 'start', endTime: 'end' },
|
||||
})
|
||||
|
||||
await executeWorkflowCore({
|
||||
snapshot: snapshot as any,
|
||||
callbacks: {},
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
|
||||
expect(getPersonalAndWorkspaceEnvMock).toHaveBeenCalledWith('workflow-owner', 'workspace-1')
|
||||
})
|
||||
|
||||
it('throws when workflowUserId is missing in server-side execution', async () => {
|
||||
const snapshot = {
|
||||
...createSnapshot(),
|
||||
metadata: {
|
||||
...createSnapshot().metadata,
|
||||
isClientSession: false,
|
||||
sessionUserId: undefined,
|
||||
workflowUserId: undefined,
|
||||
userId: 'billing-actor',
|
||||
},
|
||||
}
|
||||
|
||||
await expect(
|
||||
executeWorkflowCore({
|
||||
snapshot: snapshot as any,
|
||||
callbacks: {},
|
||||
loggingSession: loggingSession as any,
|
||||
})
|
||||
).rejects.toThrow('Missing workflowUserId in execution metadata')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -325,10 +325,13 @@ export async function executeWorkflowCore(
|
||||
|
||||
const mergedStates = mergeSubblockStateWithValues(blocks)
|
||||
|
||||
const personalEnvUserId = metadata.sessionUserId || metadata.userId
|
||||
const personalEnvUserId =
|
||||
metadata.isClientSession && metadata.sessionUserId
|
||||
? metadata.sessionUserId
|
||||
: metadata.workflowUserId
|
||||
|
||||
if (!personalEnvUserId) {
|
||||
throw new Error('Missing execution actor for environment resolution')
|
||||
throw new Error('Missing workflowUserId in execution metadata')
|
||||
}
|
||||
|
||||
const { personalEncrypted, workspaceEncrypted, personalDecrypted, workspaceDecrypted } =
|
||||
|
||||
@@ -22,6 +22,10 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
|
||||
'datasetId',
|
||||
'serviceDeskId',
|
||||
'impersonateUserEmail',
|
||||
'awsAccessKeyId',
|
||||
'awsSecretAccessKey',
|
||||
'awsRegion',
|
||||
'logGroupName',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
"@a2a-js/sdk": "0.3.7",
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.940.0",
|
||||
"@aws-sdk/client-cloudformation": "3.1019.0",
|
||||
"@aws-sdk/client-cloudwatch": "3.940.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "3.940.0",
|
||||
"@aws-sdk/client-dynamodb": "3.940.0",
|
||||
"@aws-sdk/client-rds-data": "3.940.0",
|
||||
"@aws-sdk/client-s3": "^3.779.0",
|
||||
@@ -154,8 +157,8 @@
|
||||
"papaparse": "5.5.3",
|
||||
"pdf-lib": "1.17.1",
|
||||
"postgres": "^3.4.5",
|
||||
"posthog-js": "1.334.1",
|
||||
"posthog-node": "5.9.2",
|
||||
"posthog-js": "1.364.4",
|
||||
"posthog-node": "5.28.9",
|
||||
"pptxgenjs": "4.0.1",
|
||||
"pptxviewjs": "1.1.8",
|
||||
"prismjs": "^1.30.0",
|
||||
|
||||
@@ -271,6 +271,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -290,6 +291,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -324,6 +326,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -342,6 +345,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -360,6 +364,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -373,6 +378,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -449,6 +455,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
reasoningEffort: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 100000,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
@@ -463,6 +470,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -509,7 +517,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
nativeStructuredOutputs: true,
|
||||
maxOutputTokens: 128000,
|
||||
maxOutputTokens: 64000,
|
||||
thinking: {
|
||||
levels: ['low', 'medium', 'high', 'max'],
|
||||
default: 'high',
|
||||
@@ -741,6 +749,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -759,6 +768,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -777,6 +787,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -795,6 +806,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -813,6 +825,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -831,6 +844,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
verbosity: {
|
||||
values: ['low', 'medium', 'high'],
|
||||
},
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
contextWindow: 400000,
|
||||
},
|
||||
@@ -1067,6 +1081,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
levels: ['minimal', 'low', 'medium', 'high'],
|
||||
default: 'high',
|
||||
},
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1084,6 +1099,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
levels: ['minimal', 'low', 'medium', 'high'],
|
||||
default: 'minimal',
|
||||
},
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1101,6 +1117,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
levels: ['minimal', 'low', 'medium', 'high'],
|
||||
default: 'high',
|
||||
},
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
@@ -1114,6 +1131,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1127,6 +1145,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1140,6 +1159,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1153,6 +1173,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1165,6 +1186,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 2 },
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
contextWindow: 1048576,
|
||||
},
|
||||
@@ -1178,6 +1200,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
deepResearch: true,
|
||||
memory: false,
|
||||
maxOutputTokens: 65536,
|
||||
},
|
||||
contextWindow: 1000000,
|
||||
},
|
||||
@@ -2094,6 +2117,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
nativeStructuredOutputs: true,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
@@ -2107,6 +2131,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
nativeStructuredOutputs: true,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
@@ -2120,6 +2145,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
nativeStructuredOutputs: true,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
@@ -2133,6 +2159,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
nativeStructuredOutputs: true,
|
||||
maxOutputTokens: 64000,
|
||||
},
|
||||
contextWindow: 200000,
|
||||
},
|
||||
@@ -2337,6 +2364,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 32768,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2373,6 +2401,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 16384,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2385,6 +2414,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 40000,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2397,6 +2427,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2409,6 +2440,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2421,6 +2453,7 @@ export const PROVIDER_DEFINITIONS: Record<string, ProviderDefinition> = {
|
||||
},
|
||||
capabilities: {
|
||||
temperature: { min: 0, max: 1 },
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
},
|
||||
@@ -2863,13 +2896,17 @@ export function getModelsWithoutMemory(): string[] {
|
||||
export function getMaxOutputTokensForModel(modelId: string): number {
|
||||
const normalizedModelId = modelId.toLowerCase()
|
||||
const STANDARD_MAX_OUTPUT_TOKENS = 4096
|
||||
const allModels = Object.values(PROVIDER_DEFINITIONS).flatMap((provider) => provider.models)
|
||||
|
||||
for (const provider of Object.values(PROVIDER_DEFINITIONS)) {
|
||||
for (const model of provider.models) {
|
||||
const baseModelId = model.id.toLowerCase()
|
||||
if (normalizedModelId === baseModelId || normalizedModelId.startsWith(`${baseModelId}-`)) {
|
||||
return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
const exactMatch = allModels.find((model) => model.id.toLowerCase() === normalizedModelId)
|
||||
if (exactMatch) {
|
||||
return exactMatch.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
|
||||
for (const model of allModels) {
|
||||
const baseModelId = model.id.toLowerCase()
|
||||
if (normalizedModelId.startsWith(`${baseModelId}-`)) {
|
||||
return model.capabilities.maxOutputTokens || STANDARD_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -664,6 +664,45 @@ describe('Model Capabilities', () => {
|
||||
|
||||
describe('Max Output Tokens', () => {
|
||||
describe('getMaxOutputTokensForModel', () => {
|
||||
it.concurrent('should return published max for OpenAI GPT-4o', () => {
|
||||
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(16384)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for OpenAI GPT-5.1', () => {
|
||||
expect(getMaxOutputTokensForModel('gpt-5.1')).toBe(128000)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for OpenAI GPT-5 Chat', () => {
|
||||
expect(getMaxOutputTokensForModel('gpt-5-chat-latest')).toBe(16384)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for OpenAI o1', () => {
|
||||
expect(getMaxOutputTokensForModel('o1')).toBe(100000)
|
||||
})
|
||||
|
||||
it.concurrent('should return updated max for Claude Sonnet 4.6', () => {
|
||||
expect(getMaxOutputTokensForModel('claude-sonnet-4-6')).toBe(64000)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for Gemini 2.5 Pro', () => {
|
||||
expect(getMaxOutputTokensForModel('gemini-2.5-pro')).toBe(65536)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for Azure GPT-5.2', () => {
|
||||
expect(getMaxOutputTokensForModel('azure/gpt-5.2')).toBe(128000)
|
||||
})
|
||||
|
||||
it.concurrent('should return standard default for models without maxOutputTokens', () => {
|
||||
expect(getMaxOutputTokensForModel('deepseek-reasoner')).toBe(4096)
|
||||
expect(getMaxOutputTokensForModel('grok-4-latest')).toBe(4096)
|
||||
})
|
||||
|
||||
it.concurrent('should return published max for Bedrock Claude Opus 4.1', () => {
|
||||
expect(getMaxOutputTokensForModel('bedrock/anthropic.claude-opus-4-1-20250805-v1:0')).toBe(
|
||||
64000
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('should return correct max for Claude Opus 4.6', () => {
|
||||
expect(getMaxOutputTokensForModel('claude-opus-4-6')).toBe(128000)
|
||||
})
|
||||
@@ -676,10 +715,6 @@ describe('Max Output Tokens', () => {
|
||||
expect(getMaxOutputTokensForModel('claude-opus-4-1')).toBe(32000)
|
||||
})
|
||||
|
||||
it.concurrent('should return standard default for models without maxOutputTokens', () => {
|
||||
expect(getMaxOutputTokensForModel('gpt-4o')).toBe(4096)
|
||||
})
|
||||
|
||||
it.concurrent('should return standard default for unknown models', () => {
|
||||
expect(getMaxOutputTokensForModel('unknown-model')).toBe(4096)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
CloudFormationDescribeStackDriftDetectionStatusParams,
|
||||
CloudFormationDescribeStackDriftDetectionStatusResponse,
|
||||
} from '@/tools/cloudformation/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const describeStackDriftDetectionStatusTool: ToolConfig<
|
||||
CloudFormationDescribeStackDriftDetectionStatusParams,
|
||||
CloudFormationDescribeStackDriftDetectionStatusResponse
|
||||
> = {
|
||||
id: 'cloudformation_describe_stack_drift_detection_status',
|
||||
name: 'CloudFormation Describe Stack Drift Detection Status',
|
||||
description: 'Check the status of a stack drift detection operation',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
stackDriftDetectionId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The drift detection ID returned by Detect Stack Drift',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudformation/describe-stack-drift-detection-status',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
stackDriftDetectionId: params.stackDriftDetectionId,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to describe stack drift detection status')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
stackId: data.output.stackId,
|
||||
stackDriftDetectionId: data.output.stackDriftDetectionId,
|
||||
stackDriftStatus: data.output.stackDriftStatus,
|
||||
detectionStatus: data.output.detectionStatus,
|
||||
detectionStatusReason: data.output.detectionStatusReason,
|
||||
driftedStackResourceCount: data.output.driftedStackResourceCount,
|
||||
timestamp: data.output.timestamp,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
stackId: { type: 'string', description: 'The stack ID' },
|
||||
stackDriftDetectionId: { type: 'string', description: 'The drift detection ID' },
|
||||
stackDriftStatus: {
|
||||
type: 'string',
|
||||
description: 'Drift status (DRIFTED, IN_SYNC, NOT_CHECKED)',
|
||||
},
|
||||
detectionStatus: {
|
||||
type: 'string',
|
||||
description: 'Detection status (DETECTION_IN_PROGRESS, DETECTION_COMPLETE, DETECTION_FAILED)',
|
||||
},
|
||||
detectionStatusReason: { type: 'string', description: 'Reason if detection failed' },
|
||||
driftedStackResourceCount: {
|
||||
type: 'number',
|
||||
description: 'Number of resources that have drifted',
|
||||
},
|
||||
timestamp: { type: 'number', description: 'Timestamp of the detection' },
|
||||
},
|
||||
}
|
||||
85
apps/sim/tools/cloudformation/describe_stack_events.ts
Normal file
85
apps/sim/tools/cloudformation/describe_stack_events.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
CloudFormationDescribeStackEventsParams,
|
||||
CloudFormationDescribeStackEventsResponse,
|
||||
} from '@/tools/cloudformation/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const describeStackEventsTool: ToolConfig<
|
||||
CloudFormationDescribeStackEventsParams,
|
||||
CloudFormationDescribeStackEventsResponse
|
||||
> = {
|
||||
id: 'cloudformation_describe_stack_events',
|
||||
name: 'CloudFormation Describe Stack Events',
|
||||
description: 'Get the event history for a CloudFormation stack',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
stackName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Stack name or ID',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of events to return (default: 50)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudformation/describe-stack-events',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
stackName: params.stackName,
|
||||
...(params.limit !== undefined && { limit: params.limit }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to describe CloudFormation stack events')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
events: data.output.events,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
events: {
|
||||
type: 'array',
|
||||
description: 'List of stack events with resource status and timestamps',
|
||||
},
|
||||
},
|
||||
}
|
||||
78
apps/sim/tools/cloudformation/describe_stacks.ts
Normal file
78
apps/sim/tools/cloudformation/describe_stacks.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
CloudFormationDescribeStacksParams,
|
||||
CloudFormationDescribeStacksResponse,
|
||||
} from '@/tools/cloudformation/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const describeStacksTool: ToolConfig<
|
||||
CloudFormationDescribeStacksParams,
|
||||
CloudFormationDescribeStacksResponse
|
||||
> = {
|
||||
id: 'cloudformation_describe_stacks',
|
||||
name: 'CloudFormation Describe Stacks',
|
||||
description: 'List and describe CloudFormation stacks',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
stackName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Stack name or ID to describe (omit to list all stacks)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudformation/describe-stacks',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
...(params.stackName && { stackName: params.stackName }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to describe CloudFormation stacks')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
stacks: data.output.stacks,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
stacks: {
|
||||
type: 'array',
|
||||
description: 'List of CloudFormation stacks with status, outputs, and tags',
|
||||
},
|
||||
},
|
||||
}
|
||||
78
apps/sim/tools/cloudformation/detect_stack_drift.ts
Normal file
78
apps/sim/tools/cloudformation/detect_stack_drift.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
CloudFormationDetectStackDriftParams,
|
||||
CloudFormationDetectStackDriftResponse,
|
||||
} from '@/tools/cloudformation/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const detectStackDriftTool: ToolConfig<
|
||||
CloudFormationDetectStackDriftParams,
|
||||
CloudFormationDetectStackDriftResponse
|
||||
> = {
|
||||
id: 'cloudformation_detect_stack_drift',
|
||||
name: 'CloudFormation Detect Stack Drift',
|
||||
description: 'Initiate drift detection on a CloudFormation stack',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
stackName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Stack name or ID to detect drift on',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudformation/detect-stack-drift',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
stackName: params.stackName,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to detect CloudFormation stack drift')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
stackDriftDetectionId: data.output.stackDriftDetectionId,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
stackDriftDetectionId: {
|
||||
type: 'string',
|
||||
description: 'ID to use with Describe Stack Drift Detection Status to check results',
|
||||
},
|
||||
},
|
||||
}
|
||||
77
apps/sim/tools/cloudformation/get_template.ts
Normal file
77
apps/sim/tools/cloudformation/get_template.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
CloudFormationGetTemplateParams,
|
||||
CloudFormationGetTemplateResponse,
|
||||
} from '@/tools/cloudformation/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getTemplateTool: ToolConfig<
|
||||
CloudFormationGetTemplateParams,
|
||||
CloudFormationGetTemplateResponse
|
||||
> = {
|
||||
id: 'cloudformation_get_template',
|
||||
name: 'CloudFormation Get Template',
|
||||
description: 'Retrieve the template body for a CloudFormation stack',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
stackName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Stack name or ID',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudformation/get-template',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
stackName: params.stackName,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to get CloudFormation template')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
templateBody: data.output.templateBody,
|
||||
stagesAvailable: data.output.stagesAvailable,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
templateBody: { type: 'string', description: 'The template body as a JSON or YAML string' },
|
||||
stagesAvailable: { type: 'array', description: 'Available template stages' },
|
||||
},
|
||||
}
|
||||
16
apps/sim/tools/cloudformation/index.ts
Normal file
16
apps/sim/tools/cloudformation/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describeStackDriftDetectionStatusTool } from '@/tools/cloudformation/describe_stack_drift_detection_status'
|
||||
import { describeStackEventsTool } from '@/tools/cloudformation/describe_stack_events'
|
||||
import { describeStacksTool } from '@/tools/cloudformation/describe_stacks'
|
||||
import { detectStackDriftTool } from '@/tools/cloudformation/detect_stack_drift'
|
||||
import { getTemplateTool } from '@/tools/cloudformation/get_template'
|
||||
import { listStackResourcesTool } from '@/tools/cloudformation/list_stack_resources'
|
||||
import { validateTemplateTool } from '@/tools/cloudformation/validate_template'
|
||||
|
||||
export const cloudformationDescribeStacksTool = describeStacksTool
|
||||
export const cloudformationListStackResourcesTool = listStackResourcesTool
|
||||
export const cloudformationDetectStackDriftTool = detectStackDriftTool
|
||||
export const cloudformationDescribeStackDriftDetectionStatusTool =
|
||||
describeStackDriftDetectionStatusTool
|
||||
export const cloudformationDescribeStackEventsTool = describeStackEventsTool
|
||||
export const cloudformationGetTemplateTool = getTemplateTool
|
||||
export const cloudformationValidateTemplateTool = validateTemplateTool
|
||||
78
apps/sim/tools/cloudformation/list_stack_resources.ts
Normal file
78
apps/sim/tools/cloudformation/list_stack_resources.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
CloudFormationListStackResourcesParams,
|
||||
CloudFormationListStackResourcesResponse,
|
||||
} from '@/tools/cloudformation/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listStackResourcesTool: ToolConfig<
|
||||
CloudFormationListStackResourcesParams,
|
||||
CloudFormationListStackResourcesResponse
|
||||
> = {
|
||||
id: 'cloudformation_list_stack_resources',
|
||||
name: 'CloudFormation List Stack Resources',
|
||||
description: 'List all resources in a CloudFormation stack',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
stackName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Stack name or ID',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudformation/list-stack-resources',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
stackName: params.stackName,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to list CloudFormation stack resources')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
resources: data.output.resources,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
resources: {
|
||||
type: 'array',
|
||||
description: 'List of stack resources with type, status, and drift information',
|
||||
},
|
||||
},
|
||||
}
|
||||
131
apps/sim/tools/cloudformation/types.ts
Normal file
131
apps/sim/tools/cloudformation/types.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface CloudFormationConnectionConfig {
|
||||
awsRegion: string
|
||||
awsAccessKeyId: string
|
||||
awsSecretAccessKey: string
|
||||
}
|
||||
|
||||
export interface CloudFormationDescribeStacksParams extends CloudFormationConnectionConfig {
|
||||
stackName?: string
|
||||
}
|
||||
|
||||
export interface CloudFormationListStackResourcesParams extends CloudFormationConnectionConfig {
|
||||
stackName: string
|
||||
}
|
||||
|
||||
export interface CloudFormationDetectStackDriftParams extends CloudFormationConnectionConfig {
|
||||
stackName: string
|
||||
}
|
||||
|
||||
export interface CloudFormationDescribeStackDriftDetectionStatusParams
|
||||
extends CloudFormationConnectionConfig {
|
||||
stackDriftDetectionId: string
|
||||
}
|
||||
|
||||
export interface CloudFormationDescribeStackEventsParams extends CloudFormationConnectionConfig {
|
||||
stackName: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface CloudFormationGetTemplateParams extends CloudFormationConnectionConfig {
|
||||
stackName: string
|
||||
}
|
||||
|
||||
export interface CloudFormationValidateTemplateParams extends CloudFormationConnectionConfig {
|
||||
templateBody: string
|
||||
}
|
||||
|
||||
export interface CloudFormationDescribeStacksResponse extends ToolResponse {
|
||||
output: {
|
||||
stacks: {
|
||||
stackName: string
|
||||
stackId: string
|
||||
stackStatus: string
|
||||
stackStatusReason: string | undefined
|
||||
creationTime: number | undefined
|
||||
lastUpdatedTime: number | undefined
|
||||
description: string | undefined
|
||||
enableTerminationProtection: boolean | undefined
|
||||
driftInformation: {
|
||||
stackDriftStatus: string | undefined
|
||||
lastCheckTimestamp: number | undefined
|
||||
} | null
|
||||
outputs: { outputKey: string; outputValue: string; description: string | undefined }[]
|
||||
tags: { key: string; value: string }[]
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudFormationListStackResourcesResponse extends ToolResponse {
|
||||
output: {
|
||||
resources: {
|
||||
logicalResourceId: string
|
||||
physicalResourceId: string | undefined
|
||||
resourceType: string
|
||||
resourceStatus: string
|
||||
resourceStatusReason: string | undefined
|
||||
lastUpdatedTimestamp: number | undefined
|
||||
driftInformation: {
|
||||
stackResourceDriftStatus: string | undefined
|
||||
lastCheckTimestamp: number | undefined
|
||||
} | null
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudFormationDetectStackDriftResponse extends ToolResponse {
|
||||
output: {
|
||||
stackDriftDetectionId: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudFormationDescribeStackDriftDetectionStatusResponse extends ToolResponse {
|
||||
output: {
|
||||
stackId: string
|
||||
stackDriftDetectionId: string
|
||||
stackDriftStatus: string | undefined
|
||||
detectionStatus: string
|
||||
detectionStatusReason: string | undefined
|
||||
driftedStackResourceCount: number | undefined
|
||||
timestamp: number | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudFormationDescribeStackEventsResponse extends ToolResponse {
|
||||
output: {
|
||||
events: {
|
||||
stackId: string
|
||||
eventId: string
|
||||
stackName: string
|
||||
logicalResourceId: string | undefined
|
||||
physicalResourceId: string | undefined
|
||||
resourceType: string | undefined
|
||||
resourceStatus: string | undefined
|
||||
resourceStatusReason: string | undefined
|
||||
timestamp: number | undefined
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudFormationGetTemplateResponse extends ToolResponse {
|
||||
output: {
|
||||
templateBody: string
|
||||
stagesAvailable: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudFormationValidateTemplateResponse extends ToolResponse {
|
||||
output: {
|
||||
description: string | undefined
|
||||
parameters: {
|
||||
parameterKey: string | undefined
|
||||
defaultValue: string | undefined
|
||||
noEcho: boolean | undefined
|
||||
description: string | undefined
|
||||
}[]
|
||||
capabilities: string[]
|
||||
capabilitiesReason: string | undefined
|
||||
declaredTransforms: string[]
|
||||
}
|
||||
}
|
||||
89
apps/sim/tools/cloudformation/validate_template.ts
Normal file
89
apps/sim/tools/cloudformation/validate_template.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type {
|
||||
CloudFormationValidateTemplateParams,
|
||||
CloudFormationValidateTemplateResponse,
|
||||
} from '@/tools/cloudformation/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const validateTemplateTool: ToolConfig<
|
||||
CloudFormationValidateTemplateParams,
|
||||
CloudFormationValidateTemplateResponse
|
||||
> = {
|
||||
id: 'cloudformation_validate_template',
|
||||
name: 'CloudFormation Validate Template',
|
||||
description: 'Validate a CloudFormation template for syntax and structural correctness',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
templateBody: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The CloudFormation template body (JSON or YAML)',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudformation/validate-template',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
templateBody: params.templateBody,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to validate CloudFormation template')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
description: data.output.description,
|
||||
parameters: data.output.parameters,
|
||||
capabilities: data.output.capabilities,
|
||||
capabilitiesReason: data.output.capabilitiesReason,
|
||||
declaredTransforms: data.output.declaredTransforms,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
description: { type: 'string', description: 'Template description' },
|
||||
parameters: {
|
||||
type: 'array',
|
||||
description: 'Template parameters with defaults and descriptions',
|
||||
},
|
||||
capabilities: { type: 'array', description: 'Required capabilities (e.g., CAPABILITY_IAM)' },
|
||||
capabilitiesReason: { type: 'string', description: 'Reason capabilities are required' },
|
||||
declaredTransforms: {
|
||||
type: 'array',
|
||||
description: 'Transforms used in the template (e.g., AWS::Serverless-2016-10-31)',
|
||||
},
|
||||
},
|
||||
}
|
||||
99
apps/sim/tools/cloudwatch/describe_alarms.ts
Normal file
99
apps/sim/tools/cloudwatch/describe_alarms.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type {
|
||||
CloudWatchDescribeAlarmsParams,
|
||||
CloudWatchDescribeAlarmsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const describeAlarmsTool: ToolConfig<
|
||||
CloudWatchDescribeAlarmsParams,
|
||||
CloudWatchDescribeAlarmsResponse
|
||||
> = {
|
||||
id: 'cloudwatch_describe_alarms',
|
||||
name: 'CloudWatch Describe Alarms',
|
||||
description: 'List and filter CloudWatch alarms',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
alarmNamePrefix: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter alarms by name prefix',
|
||||
},
|
||||
stateValue: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by alarm state (OK, ALARM, INSUFFICIENT_DATA)',
|
||||
},
|
||||
alarmType: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by alarm type (MetricAlarm, CompositeAlarm)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of alarms to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/describe-alarms',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
...(params.alarmNamePrefix && { alarmNamePrefix: params.alarmNamePrefix }),
|
||||
...(params.stateValue && { stateValue: params.stateValue }),
|
||||
...(params.alarmType && { alarmType: params.alarmType }),
|
||||
...(params.limit !== undefined && { limit: params.limit }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to describe CloudWatch alarms')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
alarms: data.output.alarms,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
alarms: {
|
||||
type: 'array',
|
||||
description: 'List of CloudWatch alarms with state and configuration',
|
||||
},
|
||||
},
|
||||
}
|
||||
82
apps/sim/tools/cloudwatch/describe_log_groups.ts
Normal file
82
apps/sim/tools/cloudwatch/describe_log_groups.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
CloudWatchDescribeLogGroupsParams,
|
||||
CloudWatchDescribeLogGroupsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const describeLogGroupsTool: ToolConfig<
|
||||
CloudWatchDescribeLogGroupsParams,
|
||||
CloudWatchDescribeLogGroupsResponse
|
||||
> = {
|
||||
id: 'cloudwatch_describe_log_groups',
|
||||
name: 'CloudWatch Describe Log Groups',
|
||||
description: 'List available CloudWatch log groups',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
prefix: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter log groups by name prefix',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of log groups to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/describe-log-groups',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
...(params.prefix && { prefix: params.prefix }),
|
||||
...(params.limit !== undefined && { limit: params.limit }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to describe CloudWatch log groups')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
logGroups: data.output.logGroups,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
logGroups: { type: 'array', description: 'List of CloudWatch log groups with metadata' },
|
||||
},
|
||||
}
|
||||
92
apps/sim/tools/cloudwatch/describe_log_streams.ts
Normal file
92
apps/sim/tools/cloudwatch/describe_log_streams.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
CloudWatchDescribeLogStreamsParams,
|
||||
CloudWatchDescribeLogStreamsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const describeLogStreamsTool: ToolConfig<
|
||||
CloudWatchDescribeLogStreamsParams,
|
||||
CloudWatchDescribeLogStreamsResponse
|
||||
> = {
|
||||
id: 'cloudwatch_describe_log_streams',
|
||||
name: 'CloudWatch Describe Log Streams',
|
||||
description: 'List log streams within a CloudWatch log group',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
logGroupName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'CloudWatch log group name',
|
||||
},
|
||||
prefix: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter log streams by name prefix',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of log streams to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/describe-log-streams',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
logGroupName: params.logGroupName,
|
||||
...(params.prefix && { prefix: params.prefix }),
|
||||
...(params.limit !== undefined && { limit: params.limit }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to describe CloudWatch log streams')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
logStreams: data.output.logStreams,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
logStreams: {
|
||||
type: 'array',
|
||||
description: 'List of log streams with metadata',
|
||||
},
|
||||
},
|
||||
}
|
||||
106
apps/sim/tools/cloudwatch/get_log_events.ts
Normal file
106
apps/sim/tools/cloudwatch/get_log_events.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
CloudWatchGetLogEventsParams,
|
||||
CloudWatchGetLogEventsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getLogEventsTool: ToolConfig<
|
||||
CloudWatchGetLogEventsParams,
|
||||
CloudWatchGetLogEventsResponse
|
||||
> = {
|
||||
id: 'cloudwatch_get_log_events',
|
||||
name: 'CloudWatch Get Log Events',
|
||||
description: 'Retrieve log events from a specific CloudWatch log stream',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
logGroupName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'CloudWatch log group name',
|
||||
},
|
||||
logStreamName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'CloudWatch log stream name',
|
||||
},
|
||||
startTime: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Start time as Unix epoch seconds',
|
||||
},
|
||||
endTime: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'End time as Unix epoch seconds',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of events to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/get-log-events',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
logGroupName: params.logGroupName,
|
||||
logStreamName: params.logStreamName,
|
||||
...(params.startTime !== undefined && { startTime: params.startTime }),
|
||||
...(params.endTime !== undefined && { endTime: params.endTime }),
|
||||
...(params.limit !== undefined && { limit: params.limit }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to get CloudWatch log events')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
events: data.output.events,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
events: {
|
||||
type: 'array',
|
||||
description: 'Log events with timestamp, message, and ingestion time',
|
||||
},
|
||||
},
|
||||
}
|
||||
119
apps/sim/tools/cloudwatch/get_metric_statistics.ts
Normal file
119
apps/sim/tools/cloudwatch/get_metric_statistics.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
CloudWatchGetMetricStatisticsParams,
|
||||
CloudWatchGetMetricStatisticsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getMetricStatisticsTool: ToolConfig<
|
||||
CloudWatchGetMetricStatisticsParams,
|
||||
CloudWatchGetMetricStatisticsResponse
|
||||
> = {
|
||||
id: 'cloudwatch_get_metric_statistics',
|
||||
name: 'CloudWatch Get Metric Statistics',
|
||||
description: 'Get statistics for a CloudWatch metric over a time range',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
namespace: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Metric namespace (e.g., AWS/EC2, AWS/Lambda)',
|
||||
},
|
||||
metricName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Metric name (e.g., CPUUtilization, Invocations)',
|
||||
},
|
||||
startTime: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Start time as Unix epoch seconds',
|
||||
},
|
||||
endTime: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'End time as Unix epoch seconds',
|
||||
},
|
||||
period: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Granularity in seconds (e.g., 60, 300, 3600)',
|
||||
},
|
||||
statistics: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statistics to retrieve (Average, Sum, Minimum, Maximum, SampleCount)',
|
||||
},
|
||||
dimensions: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Dimensions as JSON (e.g., {"InstanceId": "i-1234"})',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/get-metric-statistics',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
namespace: params.namespace,
|
||||
metricName: params.metricName,
|
||||
startTime: params.startTime,
|
||||
endTime: params.endTime,
|
||||
period: params.period,
|
||||
statistics: params.statistics,
|
||||
...(params.dimensions && { dimensions: params.dimensions }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to get CloudWatch metric statistics')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
label: data.output.label,
|
||||
datapoints: data.output.datapoints,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
label: { type: 'string', description: 'Metric label' },
|
||||
datapoints: { type: 'array', description: 'Datapoints with timestamp and statistics values' },
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user