Compare commits

..

123 Commits

Author SHA1 Message Date
waleed
b03d1f5d58 styling 2026-01-21 20:14:30 -08:00
Vikhyath Mondreti
be757a4f1e adhere to size limits for tables 2026-01-21 17:20:33 -08:00
Vikhyath Mondreti
1938818027 address bugbot concerns 2026-01-21 17:13:58 -08:00
Vikhyath Mondreti
2818b745d1 migrate enrichment logic to general abstraction 2026-01-21 17:08:20 -08:00
Vikhyath Mondreti
2d49de76ea add back missed code 2026-01-21 16:37:28 -08:00
Vikhyath Mondreti
1f682eb343 readd migrations 2026-01-21 16:34:32 -08:00
Vikhyath Mondreti
8d43947eb5 Merge staging into lakees/db
- Resolve merge conflicts in input-format.tsx, workflow-block.tsx, providers/utils.ts
- Fix tests to use blockData/blockNameMapping for tag variable resolution
- Add getBlockOutputs mock to block.test.ts for schema validation tests
- Fix normalizeName import path in utils.test.ts
- Add sql.raw and sql.join to drizzle-orm mock for sql.test.ts
- Add new subBlock types (table-selector, filter-builder, sort-builder) to blocks.test.ts
2026-01-21 16:33:31 -08:00
Vikhyath Mondreti
107679bf41 prepare merge 2026-01-21 16:25:13 -08:00
Lakee Sivaraya
a8e413a999 fix 2026-01-17 13:04:07 -08:00
Lakee Sivaraya
f05f5bbc6d fix 2026-01-17 12:57:37 -08:00
Lakee Sivaraya
87f8fcdbf2 fix 2026-01-17 12:49:36 -08:00
Lakee Sivaraya
6e8dc771fe fix 2026-01-17 10:16:45 -08:00
Lakee Sivaraya
d0c3c6aec7 updates 2026-01-17 10:02:52 -08:00
Lakee Sivaraya
8574d66aac uncook 2026-01-17 09:58:48 -08:00
Lakee Sivaraya
e79e9e7367 Merge origin/main into lakees/db
Resolved conflicts:
- workflow-block.tsx: Kept both table types and schedule hooks
- types.ts: Kept both filter-builder/sort-builder and deprecated comment
- icons.tsx (both apps): Kept TableIcon and added ReductoIcon/PulseIcon
- Migration files: Accepted main branch versions
2026-01-17 09:11:51 -08:00
Vikhyath Mondreti
a8bb0db660 v0.5.62: webhook bug fixes, seeding default subblock values, block selection fixes 2026-01-16 20:27:06 -08:00
Lakee Sivaraya
4b6de03a62 revert 2026-01-16 18:40:03 -08:00
Lakee Sivaraya
37b50cbce6 dedupe 2026-01-16 18:40:03 -08:00
Lakee Sivaraya
7ca628db13 rename 2026-01-16 18:40:03 -08:00
Lakee Sivaraya
118e4f65f0 updates 2026-01-16 18:40:02 -08:00
Lakee Sivaraya
292cd39cfb docs 2026-01-16 18:40:02 -08:00
Lakee Sivaraya
ea77790484 docs 2026-01-16 18:40:02 -08:00
Lakee Sivaraya
895591514a updates 2026-01-16 18:40:02 -08:00
Lakee Sivaraya
0e1133fc42 fix error handling 2026-01-16 18:40:02 -08:00
Lakee Sivaraya
4357230a9d fix 2026-01-16 18:40:02 -08:00
Lakee Sivaraya
e7f45166af type fix 2026-01-16 18:40:02 -08:00
Lakee Sivaraya
c662a31ac8 db updates 2026-01-16 18:40:02 -08:00
Lakee Sivaraya
51d1b958e2 updates 2026-01-16 18:39:17 -08:00
Lakee Sivaraya
3d81c1cc14 revert 2026-01-16 18:38:51 -08:00
Lakee Sivaraya
94c6795efc updates 2026-01-16 18:38:51 -08:00
Lakee Sivaraya
86c5e1b4ff updates 2026-01-16 18:38:51 -08:00
Lakee Sivaraya
cca1772ae1 simplify 2026-01-16 18:38:51 -08:00
Lakee Sivaraya
e4dd14df7a undo 2026-01-16 18:38:51 -08:00
Lakee Sivaraya
448b8f056c undo changes 2026-01-16 18:38:51 -08:00
Lakee Sivaraya
abb671e61b rename 2026-01-16 18:38:51 -08:00
Lakee Sivaraya
f90c9c7593 undo 2026-01-16 18:38:51 -08:00
Lakee Sivaraya
2e624c20b5 reduced type confusion 2026-01-16 18:38:51 -08:00
Lakee Sivaraya
7093209bce refactor 2026-01-16 18:38:51 -08:00
Lakee Sivaraya
897891ee1e updates 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
42aa794713 updates 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
ea72ab5aa9 simplicifcaiton 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
5173320bb5 clean comments 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
26d96624af comments 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
271375df9b rename 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
a940dd6351 updates 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
e69500726b rm 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
c94bb5acda updating prompt to make it user sort 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
fef2d2cc82 fix appearnce 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
44909964b7 fix sorting 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
1a13762617 updates 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
cfffd050a2 updates 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
d00997c5ea updates 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
466559578e validation 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
0a6312dbac better comments 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
e503408825 renames 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
ed543a71f9 u[dates 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
7f894ec023 simplify comments 2026-01-16 18:38:50 -08:00
Lakee Sivaraya
57fbd2aa1c fixes 2026-01-16 18:38:49 -08:00
Lakee Sivaraya
80270ce7b2 fix comments 2026-01-16 18:38:49 -08:00
Lakee Sivaraya
fdc3af994c updates 2026-01-16 18:38:49 -08:00
Lakee Sivaraya
5a69d16e65 wand 2026-01-16 18:38:49 -08:00
Lakee Sivaraya
c3afbaebce update db 2026-01-16 18:38:49 -08:00
Lakee Sivaraya
793c888808 undo 2026-01-16 18:37:59 -08:00
Lakee Sivaraya
ffad20efc5 updates 2026-01-16 18:37:59 -08:00
Lakee Sivaraya
b08ce03409 refactoring 2026-01-16 18:37:58 -08:00
Lakee Sivaraya
c9373c7b3e renames & refactors 2026-01-16 18:37:58 -08:00
Lakee Sivaraya
cbb93c65b6 refactoring 2026-01-16 18:37:58 -08:00
Lakee Sivaraya
96a3fe59ff updates 2026-01-16 18:37:57 -08:00
Lakee Sivaraya
df3e869f22 updates 2026-01-16 18:37:57 -08:00
Lakee Sivaraya
b3ca0c947c updates 2026-01-16 18:37:54 -08:00
Lakee Sivaraya
cfbc8d7211 dedupe 2026-01-16 18:37:54 -08:00
Lakee Sivaraya
15bef489f2 updates 2026-01-16 18:37:53 -08:00
Lakee Sivaraya
4422a69a17 revert 2026-01-16 18:37:52 -08:00
Lakee Sivaraya
8f9cf93231 changes 2026-01-16 18:37:51 -08:00
Lakee Sivaraya
22f89cf67d comments 2026-01-16 18:37:51 -08:00
Lakee Sivaraya
dfa018f2d4 updates 2026-01-16 18:37:51 -08:00
Lakee Sivaraya
e287388b03 update comments with ai 2026-01-16 18:37:51 -08:00
Lakee Sivaraya
4d176c0717 breaking down file 2026-01-16 18:37:51 -08:00
Lakee Sivaraya
c155d8ac6c doc strings 2026-01-16 18:37:50 -08:00
Lakee Sivaraya
48250f5ed8 chages 2026-01-16 18:37:50 -08:00
Lakee Sivaraya
fc6dbcf066 updates 2026-01-16 18:37:50 -08:00
Lakee Sivaraya
a537ca7ebe updates 2026-01-16 18:37:50 -08:00
Lakee Sivaraya
c1eef30578 improved errors 2026-01-16 18:37:50 -08:00
Lakee Sivaraya
6605c887ed fix lints 2026-01-16 18:37:49 -08:00
Lakee Sivaraya
a919816bff format 2026-01-16 18:37:49 -08:00
Lakee Sivaraya
8a8589e18d one input mode 2026-01-16 18:37:49 -08:00
Lakee Sivaraya
ed807bebf2 updates 2026-01-16 18:37:49 -08:00
Lakee Sivaraya
48ecb19af7 updates 2026-01-16 18:37:49 -08:00
Lakee Sivaraya
9a3d5631f2 updates 2026-01-16 18:37:47 -08:00
Lakee Sivaraya
0872314fbf filtering ui 2026-01-16 18:37:45 -08:00
Lakee Sivaraya
7e4fc32d82 updates 2026-01-16 18:37:45 -08:00
Lakee Sivaraya
4316f45175 updates 2026-01-16 18:37:45 -08:00
Lakee Sivaraya
e80660f218 trashy table viewer 2026-01-16 18:37:45 -08:00
Lakee Sivaraya
5dddb03eac required 2026-01-16 18:37:44 -08:00
Lakee Sivaraya
6386e6b437 updates 2026-01-16 18:37:44 -08:00
Waleed
af82820a28 v0.5.61: webhook improvements, workflow controls, react query for deployment status, chat fixes, reducto and pulse OCR, linear fixes 2026-01-16 18:06:23 -08:00
Waleed
4372841797 v0.5.60: invitation flow improvements, chat fixes, a2a improvements, additional copilot actions 2026-01-15 00:02:18 -08:00
Waleed
5e8c843241 v0.5.59: a2a support, documentation 2026-01-13 13:21:21 -08:00
Waleed
7bf3d73ee6 v0.5.58: export folders, new tools, permissions groups enhancements 2026-01-13 00:56:59 -08:00
Vikhyath Mondreti
7ffc11a738 v0.5.57: subagents, context menu improvements, bug fixes 2026-01-11 11:38:40 -08:00
Waleed
be578e2ed7 v0.5.56: batch operations, access control and permission groups, billing fixes 2026-01-10 00:31:34 -08:00
Waleed
f415e5edc4 v0.5.55: polling groups, bedrock provider, devcontainer fixes, workflow preview enhancements 2026-01-08 23:36:56 -08:00
Waleed
13a6e6c3fa v0.5.54: seo, model blacklist, helm chart updates, fireflies integration, autoconnect improvements, billing fixes 2026-01-07 16:09:45 -08:00
Waleed
f5ab7f21ae v0.5.53: hotkey improvements, added redis fallback, fixes for workflow tool 2026-01-06 23:34:52 -08:00
Waleed
bfb6fffe38 v0.5.52: new port-based router block, combobox expression and variable support 2026-01-06 16:14:10 -08:00
Waleed
4fbec0a43f v0.5.51: triggers, kb, condition block improvements, supabase and grain integration updates 2026-01-06 14:26:46 -08:00
Waleed
585f5e365b v0.5.50: import improvements, ui upgrades, kb styling and performance improvements 2026-01-05 00:35:55 -08:00
Waleed
3792bdd252 v0.5.49: hitl improvements, new email styles, imap trigger, logs context menu (#2672)
* feat(logs-context-menu): consolidated logs utils and types, added logs record context menu (#2659)

* feat(email): welcome email; improvement(emails): ui/ux (#2658)

* feat(email): welcome email; improvement(emails): ui/ux

* improvement(emails): links, accounts, preview

* refactor(emails): file structure and wrapper components

* added envvar for personal emails sent, added isHosted gate

* fixed failing tests, added env mock

* fix: removed comment

---------

Co-authored-by: waleed <walif6@gmail.com>

* fix(logging): hitl + trigger dev crash protection (#2664)

* hitl gaps

* deal with trigger worker crashes

* cleanup import strcuture

* feat(imap): added support for imap trigger (#2663)

* feat(tools): added support for imap trigger

* feat(imap): added parity, tested

* ack PR comments

* final cleanup

* feat(i18n): update translations (#2665)

Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>

* fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>

* feat(admin): routes to manage deployments (#2667)

* feat(admin): routes to manage deployments

* fix naming fo deployed by

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date (#2668)

* feat(time-picker): added timepicker emcn component, added to playground, added searchable prop for dropdown, added more timezones for schedule, updated license and notice date

* removed unused params, cleaned up redundant utils

* improvement(invite): aligned styling (#2669)

* improvement(invite): aligned with rest of app

* fix(invite): error handling

* fix: addressed comments

---------

Co-authored-by: Emir Karabeg <78010029+emir-karabeg@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: waleedlatif1 <waleedlatif1@users.noreply.github.com>
Co-authored-by: Adam Gough <77861281+aadamgough@users.noreply.github.com>
Co-authored-by: aadamgough <adam@sim.ai>
2026-01-03 13:19:18 -08:00
Waleed
eb5d1f3e5b v0.5.48: copy-paste workflow blocks, docs updates, mcp tool fixes 2025-12-31 18:00:04 -08:00
Waleed
54ab82c8dd v0.5.47: deploy workflow as mcp, kb chunks tokenizer, UI improvements, jira service management tools 2025-12-30 23:18:58 -08:00
Waleed
f895bf469b v0.5.46: build improvements, greptile, light mode improvements 2025-12-29 02:17:52 -08:00
Waleed
dd3209af06 v0.5.45: light mode fixes, realtime usage indicator, docker build improvements 2025-12-27 19:57:42 -08:00
Waleed
b6ba3b50a7 v0.5.44: keyboard shortcuts, autolayout, light mode, byok, testing improvements 2025-12-26 21:25:19 -08:00
Waleed
b304233062 v0.5.43: export logs, circleback, grain, vertex, code hygiene, schedule improvements 2025-12-23 19:19:18 -08:00
Vikhyath Mondreti
57e4b49bd6 v0.5.42: fix memory migration 2025-12-23 01:24:54 -08:00
Vikhyath Mondreti
e12dd204ed v0.5.41: memory fixes, copilot improvements, knowledgebase improvements, LLM providers standardization 2025-12-23 00:15:18 -08:00
Vikhyath Mondreti
3d9d9cbc54 v0.5.40: supabase ops to allow non-public schemas, jira uuid 2025-12-21 22:28:05 -08:00
Waleed
0f4ec962ad v0.5.39: notion, workflow variables fixes 2025-12-20 20:44:00 -08:00
Waleed
4827866f9a v0.5.38: snap to grid, copilot ux improvements, billing line items 2025-12-20 17:24:38 -08:00
Waleed
3e697d9ed9 v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types 2025-12-19 22:31:55 -08:00
Martin Yankov
4431a1a484 fix(helm): add custom egress rules to realtime network policy (#2481)
The realtime service network policy was missing the custom egress rules section
that allows configuration of additional egress rules via values.yaml. This caused
the realtime pods to be unable to connect to external databases (e.g., PostgreSQL
on port 5432) when using external database configurations.

The app network policy already had this section, but the realtime network policy
was missing it, creating an inconsistency and preventing the realtime service
from accessing external databases configured via networkPolicy.egress values.

This fix adds the same custom egress rules template section to the realtime
network policy, matching the app network policy behavior and allowing users to
configure database connectivity via values.yaml.
2025-12-19 18:59:08 -08:00
Waleed
4d1a9a3f22 v0.5.36: hitl improvements, opengraph, slack fixes, one-click unsubscribe, auth checks, new db indexes 2025-12-19 01:27:49 -08:00
Vikhyath Mondreti
eb07a080fb v0.5.35: helm updates, copilot improvements, 404 for docs, salesforce fixes, subflow resize clamping 2025-12-18 16:23:19 -08:00
378 changed files with 16785 additions and 8931 deletions

View File

@@ -4696,6 +4696,26 @@ export function BedrockIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function TableIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth={2}
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<rect width='18' height='18' x='3' y='3' rx='2' />
<path d='M3 9h18' />
<path d='M3 15h18' />
<path d='M9 3v18' />
<path d='M15 3v18' />
</svg>
)
}
export function ReductoIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -108,6 +108,7 @@ import {
StagehandIcon,
StripeIcon,
SupabaseIcon,
TableIcon,
TavilyIcon,
TelegramIcon,
TextractIcon,
@@ -236,6 +237,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
stripe: StripeIcon,
stt: STTIcon,
supabase: SupabaseIcon,
table: TableIcon,
tavily: TavilyIcon,
telegram: TelegramIcon,
textract: TextractIcon,

View File

@@ -104,6 +104,7 @@
"stripe",
"stt",
"supabase",
"table",
"tavily",
"telegram",
"textract",

View File

@@ -0,0 +1,351 @@
---
title: Table
description: User-defined data tables for storing and querying structured data
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="table"
color="#10B981"
/>
Tables allow you to create and manage custom data tables directly within Sim. Store, query, and manipulate structured data within your workflows without needing external database integrations.
**Why Use Tables?**
- **No external setup**: Create tables instantly without configuring external databases
- **Workflow-native**: Data persists across workflow executions and is accessible from any workflow in your workspace
- **Flexible schema**: Define columns with types (string, number, boolean, date, json) and constraints (required, unique)
- **Powerful querying**: Filter, sort, and paginate data using MongoDB-style operators
- **Agent-friendly**: Tables can be used as tools by AI agents for dynamic data storage and retrieval
**Key Features:**
- Create tables with custom schemas
- Insert, update, upsert, and delete rows
- Query with filters and sorting
- Batch operations for bulk inserts
- Bulk updates and deletes by filter
- Up to 10,000 rows per table, 100 tables per workspace
## Creating Tables
Tables are created from the **Tables** section in the sidebar. Each table requires:
- **Name**: Alphanumeric with underscores (e.g., `customer_leads`)
- **Description**: Optional description of the table's purpose
- **Schema**: Define columns with name, type, and optional constraints
### Column Types
| Type | Description | Example Values |
|------|-------------|----------------|
| `string` | Text data | `"John Doe"`, `"active"` |
| `number` | Numeric data | `42`, `99.99` |
| `boolean` | True/false values | `true`, `false` |
| `date` | Date/time values | `"2024-01-15T10:30:00Z"` |
| `json` | Complex nested data | `{"address": {"city": "NYC"}}` |
### Column Constraints
- **Required**: Column must have a value (cannot be null)
- **Unique**: Values must be unique across all rows (enables upsert matching)
## Usage Instructions
Create and manage custom data tables. Store, query, and manipulate structured data within workflows.
## Tools
### `table_query_rows`
Query rows from a table with filtering, sorting, and pagination
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `filter` | object | No | Filter conditions using MongoDB-style operators |
| `sort` | object | No | Sort order as \{column: "asc"\|"desc"\} |
| `limit` | number | No | Maximum rows to return \(default: 100, max: 1000\) |
| `offset` | number | No | Number of rows to skip \(default: 0\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether query succeeded |
| `rows` | array | Query result rows |
| `rowCount` | number | Number of rows returned |
| `totalCount` | number | Total rows matching filter |
| `limit` | number | Limit used in query |
| `offset` | number | Offset used in query |
### `table_insert_row`
Insert a new row into a table
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `data` | object | Yes | Row data as JSON object matching the table schema |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether row was inserted |
| `row` | object | Inserted row data including generated ID |
| `message` | string | Status message |
### `table_upsert_row`
Insert or update a row based on unique column constraints. If a row with matching unique field exists, update it; otherwise insert a new row.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `data` | object | Yes | Row data to insert or update |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether row was upserted |
| `row` | object | Upserted row data |
| `operation` | string | Operation performed: "insert" or "update" |
| `message` | string | Status message |
### `table_batch_insert_rows`
Insert multiple rows at once (up to 1000 rows per batch)
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `rows` | array | Yes | Array of row data objects to insert |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether batch insert succeeded |
| `rows` | array | Array of inserted rows with IDs |
| `insertedCount` | number | Number of rows inserted |
| `message` | string | Status message |
### `table_update_row`
Update a specific row by its ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `rowId` | string | Yes | Row ID to update |
| `data` | object | Yes | Data to update \(partial update supported\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether row was updated |
| `row` | object | Updated row data |
| `message` | string | Status message |
### `table_update_rows_by_filter`
Update multiple rows matching a filter condition
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `filter` | object | Yes | Filter to match rows for update |
| `data` | object | Yes | Data to apply to matching rows |
| `limit` | number | No | Maximum rows to update \(default: 1000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether update succeeded |
| `updatedCount` | number | Number of rows updated |
| `updatedRowIds` | array | IDs of updated rows |
| `message` | string | Status message |
### `table_delete_row`
Delete a specific row by its ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `rowId` | string | Yes | Row ID to delete |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether row was deleted |
| `deletedCount` | number | Number of rows deleted \(1 or 0\) |
| `message` | string | Status message |
### `table_delete_rows_by_filter`
Delete multiple rows matching a filter condition
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `filter` | object | Yes | Filter to match rows for deletion |
| `limit` | number | No | Maximum rows to delete \(default: 1000\) |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether delete succeeded |
| `deletedCount` | number | Number of rows deleted |
| `deletedRowIds` | array | IDs of deleted rows |
| `message` | string | Status message |
### `table_get_row`
Get a single row by its ID
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
| `rowId` | string | Yes | Row ID to retrieve |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether row was found |
| `row` | object | Row data |
| `message` | string | Status message |
### `table_get_schema`
Get the schema definition for a table
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `tableId` | string | Yes | Table ID |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `success` | boolean | Whether schema was retrieved |
| `name` | string | Table name |
| `columns` | array | Array of column definitions |
| `message` | string | Status message |
## Filter Operators
Filters use MongoDB-style operators for flexible querying:
| Operator | Description | Example |
|----------|-------------|---------|
| `$eq` | Equals | `{"status": {"$eq": "active"}}` or `{"status": "active"}` |
| `$ne` | Not equals | `{"status": {"$ne": "deleted"}}` |
| `$gt` | Greater than | `{"age": {"$gt": 18}}` |
| `$gte` | Greater than or equal | `{"score": {"$gte": 80}}` |
| `$lt` | Less than | `{"price": {"$lt": 100}}` |
| `$lte` | Less than or equal | `{"quantity": {"$lte": 10}}` |
| `$in` | In array | `{"status": {"$in": ["active", "pending"]}}` |
| `$nin` | Not in array | `{"type": {"$nin": ["spam", "blocked"]}}` |
| `$contains` | String contains | `{"email": {"$contains": "@gmail.com"}}` |
### Combining Filters
Multiple field conditions are combined with AND logic:
```json
{
"status": "active",
"age": {"$gte": 18}
}
```
Use `$or` for OR logic:
```json
{
"$or": [
{"status": "active"},
{"status": "pending"}
]
}
```
## Sort Specification
Specify sort order with column names and direction:
```json
{
"createdAt": "desc"
}
```
Multi-column sorting:
```json
{
"priority": "desc",
"name": "asc"
}
```
## Built-in Columns
Every row automatically includes:
| Column | Type | Description |
|--------|------|-------------|
| `id` | string | Unique row identifier |
| `createdAt` | date | When the row was created |
| `updatedAt` | date | When the row was last modified |
These can be used in filters and sorting.
## Limits
| Resource | Limit |
|----------|-------|
| Tables per workspace | 100 |
| Rows per table | 10,000 |
| Columns per table | 50 |
| Max row size | 100KB |
| String value length | 10,000 characters |
| Query limit | 1,000 rows |
| Batch insert size | 1,000 rows |
| Bulk update/delete | 1,000 rows |
## Notes
- Category: `blocks`
- Type: `table`
- Tables are scoped to workspaces and accessible from any workflow within that workspace
- Data persists across workflow executions
- Use unique constraints to enable upsert functionality
- The visual filter/sort builder provides an easy way to construct queries without writing JSON

View File

@@ -2,9 +2,10 @@
import { useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
@@ -21,7 +22,6 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
@@ -107,6 +107,7 @@ export default function LoginPage({
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
const buttonClass = useBrandedButtonClass()
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [callbackUrl, setCallbackUrl] = useState('/workspace')
const [isInviteFlow, setIsInviteFlow] = useState(false)
@@ -114,6 +115,7 @@ export default function LoginPage({
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
const [isResetButtonHovered, setIsResetButtonHovered] = useState(false)
const [resetStatus, setResetStatus] = useState<{
type: 'success' | 'error' | null
message: string
@@ -182,13 +184,6 @@ export default function LoginPage({
e.preventDefault()
setIsLoading(true)
const redirectToVerify = (emailToVerify: string) => {
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', emailToVerify)
}
router.push('/verify')
}
const formData = new FormData(e.currentTarget)
const emailRaw = formData.get('email') as string
const email = emailRaw.trim().toLowerCase()
@@ -220,9 +215,9 @@ export default function LoginPage({
onError: (ctx) => {
logger.error('Login error:', ctx.error)
// EMAIL_NOT_VERIFIED is handled by the catch block which redirects to /verify
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
errorHandled = true
redirectToVerify(email)
return
}
@@ -290,7 +285,10 @@ export default function LoginPage({
router.push(safeCallbackUrl)
} catch (err: any) {
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
redirectToVerify(email)
if (typeof window !== 'undefined') {
sessionStorage.setItem('verificationEmail', email)
}
router.push('/verify')
return
}
@@ -493,14 +491,24 @@ export default function LoginPage({
</div>
</div>
<BrandedButton
<Button
type='submit'
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
disabled={isLoading}
loading={isLoading}
loadingText='Signing in'
>
Sign in
</BrandedButton>
<span className='flex items-center gap-1'>
{isLoading ? 'Signing in...' : 'Sign in'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
</form>
)}
@@ -611,15 +619,25 @@ export default function LoginPage({
<p>{resetStatus.message}</p>
</div>
)}
<BrandedButton
<Button
type='button'
onClick={handleForgotPassword}
onMouseEnter={() => setIsResetButtonHovered(true)}
onMouseLeave={() => setIsResetButtonHovered(false)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
disabled={isSubmittingReset}
loading={isSubmittingReset}
loadingText='Sending'
>
Send Reset Link
</BrandedButton>
<span className='flex items-center gap-1'>
{isSubmittingReset ? 'Sending...' : 'Send Reset Link'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isResetButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
</div>
</DialogContent>
</Dialog>

View File

@@ -1,12 +1,13 @@
'use client'
import { useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/core/utils/cn'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
interface RequestResetFormProps {
email: string
@@ -27,6 +28,9 @@ export function RequestResetForm({
statusMessage,
className,
}: RequestResetFormProps) {
const buttonClass = useBrandedButtonClass()
const [isButtonHovered, setIsButtonHovered] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
onSubmit(email)
@@ -64,14 +68,24 @@ export function RequestResetForm({
)}
</div>
<BrandedButton
<Button
type='submit'
disabled={isSubmitting}
loading={isSubmitting}
loadingText='Sending'
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
>
Send Reset Link
</BrandedButton>
<span className='flex items-center gap-1'>
{isSubmitting ? 'Sending...' : 'Send Reset Link'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
</form>
)
}
@@ -98,6 +112,8 @@ export function SetNewPasswordForm({
const [validationMessage, setValidationMessage] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const buttonClass = useBrandedButtonClass()
const [isButtonHovered, setIsButtonHovered] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -227,14 +243,24 @@ export function SetNewPasswordForm({
)}
</div>
<BrandedButton
type='submit'
<Button
disabled={isSubmitting || !token}
loading={isSubmitting}
loadingText='Resetting'
type='submit'
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
>
Reset Password
</BrandedButton>
<span className='flex items-center gap-1'>
{isSubmitting ? 'Resetting...' : 'Reset Password'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
</form>
)
}

View File

@@ -2,9 +2,10 @@
import { Suspense, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Eye, EyeOff } from 'lucide-react'
import { ArrowRight, ChevronRight, Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { client, useSession } from '@/lib/auth/auth-client'
@@ -13,7 +14,6 @@ import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons'
import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
@@ -97,6 +97,7 @@ function SignupFormContent({
const [redirectUrl, setRedirectUrl] = useState('')
const [isInviteFlow, setIsInviteFlow] = useState(false)
const buttonClass = useBrandedButtonClass()
const [isButtonHovered, setIsButtonHovered] = useState(false)
const [name, setName] = useState('')
const [nameErrors, setNameErrors] = useState<string[]>([])
@@ -475,14 +476,24 @@ function SignupFormContent({
</div>
</div>
<BrandedButton
<Button
type='submit'
onMouseEnter={() => setIsButtonHovered(true)}
onMouseLeave={() => setIsButtonHovered(false)}
className='group inline-flex w-full items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all'
disabled={isLoading}
loading={isLoading}
loadingText='Creating account'
>
Create account
</BrandedButton>
<span className='flex items-center gap-1'>
{isLoading ? 'Creating account' : 'Create account'}
<span className='inline-flex transition-transform duration-200 group-hover:translate-x-0.5'>
{isButtonHovered ? (
<ArrowRight className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronRight className='h-4 w-4' aria-hidden='true' />
)}
</span>
</span>
</Button>
</form>
)}

View File

@@ -4,6 +4,7 @@ import { useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { X } from 'lucide-react'
import { Textarea } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -17,7 +18,6 @@ import { isHosted } from '@/lib/core/config/feature-flags'
import { cn } from '@/lib/core/utils/cn'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedButton } from '@/app/(auth)/components/branded-button'
import Footer from '@/app/(landing)/components/footer/footer'
import Nav from '@/app/(landing)/components/nav/nav'
@@ -493,17 +493,18 @@ export default function CareersPage() {
{/* Submit Button */}
<div className='flex justify-end pt-2'>
<BrandedButton
<Button
type='submit'
disabled={isSubmitting || submitStatus === 'success'}
loading={isSubmitting}
loadingText='Submitting'
showArrow={false}
fullWidth={false}
className='min-w-[200px]'
className='min-w-[200px] rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all duration-300 hover:opacity-90 disabled:opacity-50'
size='lg'
>
{submitStatus === 'success' ? 'Submitted' : 'Submit Application'}
</BrandedButton>
{isSubmitting
? 'Submitting...'
: submitStatus === 'success'
? 'Submitted'
: 'Submit Application'}
</Button>
</div>
</form>
</section>

View File

@@ -59,7 +59,7 @@ export default function StatusIndicator() {
href={statusUrl}
target='_blank'
rel='noopener noreferrer'
className={`flex min-w-[165px] items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
className={`flex items-center gap-[6px] whitespace-nowrap text-[12px] transition-colors ${STATUS_COLORS[status]}`}
aria-label={`System status: ${message}`}
>
<StatusDotIcon status={status} className='h-[6px] w-[6px]' aria-hidden='true' />

View File

@@ -11,7 +11,6 @@ import { useBrandConfig } from '@/lib/branding/branding'
import { isHosted } from '@/lib/core/config/feature-flags'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
const logger = createLogger('nav')
@@ -21,12 +20,11 @@ interface NavProps {
}
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
const [githubStars, setGithubStars] = useState('25.8k')
const [githubStars, setGithubStars] = useState('25.1k')
const [isHovered, setIsHovered] = useState(false)
const [isLoginHovered, setIsLoginHovered] = useState(false)
const router = useRouter()
const brand = useBrandConfig()
const buttonClass = useBrandedButtonClass()
useEffect(() => {
if (variant !== 'landing') return
@@ -185,7 +183,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
href='/signup'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all`}
className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]'
aria-label='Get started with Sim - Sign up for free'
prefetch={true}
>

View File

@@ -1,27 +0,0 @@
'use client'
import { useState } from 'react'
import { ArrowLeft, ChevronLeft } from 'lucide-react'
import Link from 'next/link'
export function BackLink() {
const [isHovered, setIsHovered] = useState(false)
return (
<Link
href='/studio'
className='group flex items-center gap-1 text-gray-600 text-sm hover:text-gray-900'
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<span className='group-hover:-translate-x-0.5 inline-flex transition-transform duration-200'>
{isHovered ? (
<ArrowLeft className='h-4 w-4' aria-hidden='true' />
) : (
<ChevronLeft className='h-4 w-4' aria-hidden='true' />
)}
</span>
Back to Sim Studio
</Link>
)
}

View File

@@ -5,10 +5,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/emcn'
import { FAQ } from '@/lib/blog/faq'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
import { buildArticleJsonLd, buildBreadcrumbJsonLd, buildPostMetadata } from '@/lib/blog/seo'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BackLink } from '@/app/(landing)/studio/[slug]/back-link'
import { ShareButton } from '@/app/(landing)/studio/[slug]/share-button'
export async function generateStaticParams() {
const posts = await getAllPostMeta()
@@ -51,7 +48,9 @@ export default async function Page({ params }: { params: Promise<{ slug: string
/>
<header className='mx-auto max-w-[1450px] px-6 pt-8 sm:px-8 sm:pt-12 md:px-12 md:pt-16'>
<div className='mb-6'>
<BackLink />
<Link href='/studio' className='text-gray-600 text-sm hover:text-gray-900'>
Back to Sim Studio
</Link>
</div>
<div className='flex flex-col gap-8 md:flex-row md:gap-12'>
<div className='w-full flex-shrink-0 md:w-[450px]'>
@@ -76,31 +75,28 @@ export default async function Page({ params }: { params: Promise<{ slug: string
>
{post.title}
</h1>
<div className='mt-4 flex items-center justify-between'>
<div className='flex items-center gap-3'>
{(post.authors || [post.author]).map((a, idx) => (
<div key={idx} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-6'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
>
<span itemProp='name'>{a?.name}</span>
</Link>
</div>
))}
</div>
<ShareButton url={`${getBaseUrl()}/studio/${slug}`} title={post.title} />
<div className='mt-4 flex items-center gap-3'>
{(post.authors || [post.author]).map((a, idx) => (
<div key={idx} className='flex items-center gap-2'>
{a?.avatarUrl ? (
<Avatar className='size-6'>
<AvatarImage src={a.avatarUrl} alt={a.name} />
<AvatarFallback>{a.name.slice(0, 2)}</AvatarFallback>
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
className='text-[14px] text-gray-600 leading-[1.5] hover:text-gray-900 sm:text-[16px]'
itemProp='author'
itemScope
itemType='https://schema.org/Person'
>
<span itemProp='name'>{a?.name}</span>
</Link>
</div>
))}
</div>
</div>
</div>

View File

@@ -1,65 +0,0 @@
'use client'
import { useState } from 'react'
import { Share2 } from 'lucide-react'
import { Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
interface ShareButtonProps {
url: string
title: string
}
export function ShareButton({ url, title }: ShareButtonProps) {
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => {
setCopied(false)
setOpen(false)
}, 1000)
} catch {
setOpen(false)
}
}
const handleShareTwitter = () => {
const tweetUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
window.open(tweetUrl, '_blank', 'noopener,noreferrer')
setOpen(false)
}
const handleShareLinkedIn = () => {
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`
window.open(linkedInUrl, '_blank', 'noopener,noreferrer')
setOpen(false)
}
return (
<Popover
open={open}
onOpenChange={setOpen}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverTrigger asChild>
<button
className='flex items-center gap-1.5 text-gray-600 text-sm hover:text-gray-900'
aria-label='Share this post'
>
<Share2 className='h-4 w-4' />
<span>Share</span>
</button>
</PopoverTrigger>
<PopoverContent align='end' minWidth={140}>
<PopoverItem onClick={handleCopyLink}>{copied ? 'Copied!' : 'Copy link'}</PopoverItem>
<PopoverItem onClick={handleShareTwitter}>Share on X</PopoverItem>
<PopoverItem onClick={handleShareLinkedIn}>Share on LinkedIn</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -22,7 +22,7 @@ export default async function StudioIndex({
? filtered.sort((a, b) => {
if (a.featured && !b.featured) return -1
if (!a.featured && b.featured) return 1
return new Date(b.date).getTime() - new Date(a.date).getTime()
return 0
})
: filtered

View File

@@ -8,7 +8,6 @@ import type { AgentCapabilities, AgentSkill } from '@/lib/a2a/types'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getRedisClient } from '@/lib/core/config/redis'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('A2AAgentCardAPI')
@@ -96,11 +95,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<Ro
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
if (
@@ -166,11 +160,6 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
await db.delete(a2aAgent).where(eq(a2aAgent.id, agentId))
logger.info(`Deleted A2A agent: ${agentId}`)
@@ -205,11 +194,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
}
const workspaceAccess = await checkWorkspaceAccess(existingAgent.workspaceId, auth.userId)
if (!workspaceAccess.canWrite) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const action = body.action as 'publish' | 'unpublish' | 'refresh'

View File

@@ -16,7 +16,6 @@ import {
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { getBrandConfig } from '@/lib/branding/branding'
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { markExecutionCancelled } from '@/lib/execution/cancellation'
@@ -1119,13 +1118,17 @@ async function handlePushNotificationSet(
)
}
const urlValidation = validateExternalUrl(
params.pushNotificationConfig.url,
'Push notification URL'
)
if (!urlValidation.isValid) {
try {
const url = new URL(params.pushNotificationConfig.url)
if (url.protocol !== 'https:') {
return NextResponse.json(
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Push notification URL must use HTTPS'),
{ status: 400 }
)
}
} catch {
return NextResponse.json(
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, urlValidation.error || 'Invalid URL'),
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Invalid push notification URL'),
{ status: 400 }
)
}

View File

@@ -4,11 +4,6 @@ import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { refreshOAuthToken } from '@/lib/oauth'
import {
getMicrosoftRefreshTokenExpiry,
isMicrosoftProvider,
PROACTIVE_REFRESH_THRESHOLD_DAYS,
} from '@/lib/oauth/microsoft'
const logger = createLogger('OAuthUtilsAPI')
@@ -210,32 +205,15 @@ export async function refreshAccessTokenIfNeeded(
}
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const expiresAt = credential.accessTokenExpiresAt
const now = new Date()
// Check if access token needs refresh (missing or expired)
const accessTokenNeedsRefresh =
!!credential.refreshToken &&
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
const shouldRefresh =
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
const accessToken = credential.accessToken
if (shouldRefresh) {
logger.info(`[${requestId}] Refreshing token for credential`)
logger.info(`[${requestId}] Token expired, attempting to refresh for credential`)
try {
const refreshedToken = await refreshOAuthToken(
credential.providerId,
@@ -249,15 +227,11 @@ export async function refreshAccessTokenIfNeeded(
userId: credential.userId,
hasRefreshToken: !!credential.refreshToken,
})
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return accessToken
}
return null
}
// Prepare update data
const updateData: Record<string, unknown> = {
const updateData: any = {
accessToken: refreshedToken.accessToken,
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000),
updatedAt: new Date(),
@@ -269,10 +243,6 @@ export async function refreshAccessTokenIfNeeded(
updateData.refreshToken = refreshedToken.refreshToken
}
if (isMicrosoftProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
// Update the token in the database
await db.update(account).set(updateData).where(eq(account.id, credentialId))
@@ -286,10 +256,6 @@ export async function refreshAccessTokenIfNeeded(
credentialId,
userId: credential.userId,
})
if (!accessTokenNeedsRefresh && accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return accessToken
}
return null
}
} else if (!accessToken) {
@@ -311,27 +277,10 @@ export async function refreshTokenIfNeeded(
credentialId: string
): Promise<{ accessToken: string; refreshed: boolean }> {
// Decide if we should refresh: token missing OR expired
const accessTokenExpiresAt = credential.accessTokenExpiresAt
const refreshTokenExpiresAt = credential.refreshTokenExpiresAt
const expiresAt = credential.accessTokenExpiresAt
const now = new Date()
// Check if access token needs refresh (missing or expired)
const accessTokenNeedsRefresh =
!!credential.refreshToken &&
(!credential.accessToken || (accessTokenExpiresAt && accessTokenExpiresAt <= now))
// Check if we should proactively refresh to prevent refresh token expiry
// This applies to Microsoft providers whose refresh tokens expire after 90 days of inactivity
const proactiveRefreshThreshold = new Date(
now.getTime() + PROACTIVE_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000
)
const refreshTokenNeedsProactiveRefresh =
!!credential.refreshToken &&
isMicrosoftProvider(credential.providerId) &&
refreshTokenExpiresAt &&
refreshTokenExpiresAt <= proactiveRefreshThreshold
const shouldRefresh = accessTokenNeedsRefresh || refreshTokenNeedsProactiveRefresh
const shouldRefresh =
!!credential.refreshToken && (!credential.accessToken || (expiresAt && expiresAt <= now))
// If token appears valid and present, return it directly
if (!shouldRefresh) {
@@ -344,17 +293,13 @@ export async function refreshTokenIfNeeded(
if (!refreshResult) {
logger.error(`[${requestId}] Failed to refresh token for credential`)
if (!accessTokenNeedsRefresh && credential.accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return { accessToken: credential.accessToken, refreshed: false }
}
throw new Error('Failed to refresh token')
}
const { accessToken: refreshedToken, expiresIn, refreshToken: newRefreshToken } = refreshResult
// Prepare update data
const updateData: Record<string, unknown> = {
const updateData: any = {
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
updatedAt: new Date(),
@@ -366,10 +311,6 @@ export async function refreshTokenIfNeeded(
updateData.refreshToken = newRefreshToken
}
if (isMicrosoftProvider(credential.providerId)) {
updateData.refreshTokenExpiresAt = getMicrosoftRefreshTokenExpiry()
}
await db.update(account).set(updateData).where(eq(account.id, credentialId))
logger.info(`[${requestId}] Successfully refreshed access token`)
@@ -390,11 +331,6 @@ export async function refreshTokenIfNeeded(
}
}
if (!accessTokenNeedsRefresh && credential.accessToken) {
logger.info(`[${requestId}] Proactive refresh failed but access token still valid`)
return { accessToken: credential.accessToken, refreshed: false }
}
logger.error(`[${requestId}] Refresh failed and no valid token found in DB`, error)
throw error
}

View File

@@ -104,11 +104,17 @@ export async function POST(req: NextRequest) {
})
// Build execution params starting with LLM-provided arguments
// Resolve all {{ENV_VAR}} references in the arguments (deep for nested objects)
// Resolve all {{ENV_VAR}} references in the arguments
const executionParams: Record<string, any> = resolveEnvVarReferences(
toolArgs,
decryptedEnvVars,
{ deep: true }
{
resolveExactMatch: true,
allowEmbedded: true,
trimKeys: true,
onMissing: 'keep',
deep: true,
}
) as Record<string, any>
logger.info(`[${tracker.requestId}] Resolved env var references in arguments`, {

View File

@@ -84,14 +84,6 @@ vi.mock('@/lib/execution/isolated-vm', () => ({
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/auth/hybrid', () => ({
checkHybridAuth: vi.fn().mockResolvedValue({
success: true,
userId: 'user-123',
authType: 'session',
}),
}))
vi.mock('@/lib/execution/e2b', () => ({
executeInE2B: vi.fn(),
}))
@@ -118,24 +110,6 @@ describe('Function Execute API Route', () => {
})
describe('Security Tests', () => {
it('should reject unauthorized requests', async () => {
const { checkHybridAuth } = await import('@/lib/auth/hybrid')
vi.mocked(checkHybridAuth).mockResolvedValueOnce({
success: false,
error: 'Unauthorized',
})
const req = createMockRequest('POST', {
code: 'return "test"',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Unauthorized')
})
it.concurrent('should use isolated-vm for secure sandboxed execution', async () => {
const req = createMockRequest('POST', {
code: 'return "test"',
@@ -339,7 +313,7 @@ describe('Function Execute API Route', () => {
'block-2': 'world',
},
blockNameMapping: {
validvar: 'block-1',
validVar: 'block-1',
another_valid: 'block-2',
},
})
@@ -565,7 +539,7 @@ describe('Function Execute API Route', () => {
'block-complex': complexData,
},
blockNameMapping: {
complexdata: 'block-complex',
complexData: 'block-complex',
},
})

View File

@@ -1,17 +1,16 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { isE2bEnabled } from '@/lib/core/config/feature-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { executeInE2B } from '@/lib/execution/e2b'
import { executeInIsolatedVM } from '@/lib/execution/isolated-vm'
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
import {
createEnvVarPattern,
createWorkflowVariablePattern,
} from '@/executor/utils/reference-validation'
import { navigatePath } from '@/executor/variables/resolvers/reference'
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
@@ -471,17 +470,14 @@ function resolveEnvironmentVariables(
function resolveTagVariables(
code: string,
blockData: Record<string, unknown>,
blockData: Record<string, any>,
blockNameMapping: Record<string, string>,
blockOutputSchemas: Record<string, OutputSchema>,
contextVariables: Record<string, unknown>,
language = 'javascript'
contextVariables: Record<string, any>
): string {
let resolvedCode = code
const undefinedLiteral = language === 'python' ? 'None' : 'undefined'
const tagPattern = new RegExp(
`${REFERENCE.START}([a-zA-Z_](?:[a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])?)${REFERENCE.END}`,
`${REFERENCE.START}([a-zA-Z_][a-zA-Z0-9_${REFERENCE.PATH_DELIMITER}]*[a-zA-Z0-9_])${REFERENCE.END}`,
'g'
)
const tagMatches = resolvedCode.match(tagPattern) || []
@@ -490,37 +486,41 @@ function resolveTagVariables(
const tagName = match.slice(REFERENCE.START.length, -REFERENCE.END.length).trim()
const pathParts = tagName.split(REFERENCE.PATH_DELIMITER)
const blockName = pathParts[0]
const fieldPath = pathParts.slice(1)
const result = resolveBlockReference(blockName, fieldPath, {
blockNameMapping,
blockData,
blockOutputSchemas,
})
if (!result) {
const blockId = blockNameMapping[blockName]
if (!blockId) {
continue
}
let tagValue = result.value
const blockOutput = blockData[blockId]
if (blockOutput === undefined) {
continue
}
let tagValue: any
if (pathParts.length === 1) {
tagValue = blockOutput
} else {
tagValue = navigatePath(blockOutput, pathParts.slice(1))
}
if (tagValue === undefined) {
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), undefinedLiteral)
continue
}
if (typeof tagValue === 'string') {
const trimmed = tagValue.trimStart()
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
tagValue = JSON.parse(tagValue)
} catch {
// Keep as string if not valid JSON
}
if (
typeof tagValue === 'string' &&
tagValue.length > 100 &&
(tagValue.startsWith('{') || tagValue.startsWith('['))
) {
try {
tagValue = JSON.parse(tagValue)
} catch {
// Keep as-is
}
}
const safeVarName = `__tag_${tagName.replace(/_/g, '_1').replace(/\./g, '_0')}`
const safeVarName = `__tag_${tagName.replace(/[^a-zA-Z0-9_]/g, '_')}`
contextVariables[safeVarName] = tagValue
resolvedCode = resolvedCode.replace(new RegExp(escapeRegExp(match), 'g'), safeVarName)
}
@@ -537,27 +537,18 @@ function resolveTagVariables(
*/
function resolveCodeVariables(
code: string,
params: Record<string, unknown>,
params: Record<string, any>,
envVars: Record<string, string> = {},
blockData: Record<string, unknown> = {},
blockData: Record<string, any> = {},
blockNameMapping: Record<string, string> = {},
blockOutputSchemas: Record<string, OutputSchema> = {},
workflowVariables: Record<string, unknown> = {},
language = 'javascript'
): { resolvedCode: string; contextVariables: Record<string, unknown> } {
workflowVariables: Record<string, any> = {}
): { resolvedCode: string; contextVariables: Record<string, any> } {
let resolvedCode = code
const contextVariables: Record<string, unknown> = {}
const contextVariables: Record<string, any> = {}
resolvedCode = resolveWorkflowVariables(resolvedCode, workflowVariables, contextVariables)
resolvedCode = resolveEnvironmentVariables(resolvedCode, params, envVars, contextVariables)
resolvedCode = resolveTagVariables(
resolvedCode,
blockData,
blockNameMapping,
blockOutputSchemas,
contextVariables,
language
)
resolvedCode = resolveTagVariables(resolvedCode, blockData, blockNameMapping, contextVariables)
return { resolvedCode, contextVariables }
}
@@ -582,12 +573,6 @@ export async function POST(req: NextRequest) {
let resolvedCode = '' // Store resolved code for error reporting
try {
const auth = await checkHybridAuth(req)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized function execution attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { DEFAULT_EXECUTION_TIMEOUT_MS } = await import('@/lib/execution/constants')
@@ -600,7 +585,6 @@ export async function POST(req: NextRequest) {
envVars = {},
blockData = {},
blockNameMapping = {},
blockOutputSchemas = {},
workflowVariables = {},
workflowId,
isCustomTool = false,
@@ -617,21 +601,20 @@ export async function POST(req: NextRequest) {
isCustomTool,
})
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
// Resolve variables in the code with workflow environment variables
const codeResolution = resolveCodeVariables(
code,
executionParams,
envVars,
blockData,
blockNameMapping,
blockOutputSchemas,
workflowVariables,
lang
workflowVariables
)
resolvedCode = codeResolution.resolvedCode
const contextVariables = codeResolution.contextVariables
const lang = isValidCodeLanguage(language) ? language : DEFAULT_CODE_LANGUAGE
let jsImports = ''
let jsRemainingCode = resolvedCode
let hasImports = false
@@ -687,11 +670,7 @@ export async function POST(req: NextRequest) {
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
if (v === undefined) {
prologue += `const ${k} = undefined;\n`
} else {
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
}
prologue += `const ${k} = JSON.parse(${JSON.stringify(JSON.stringify(v))});\n`
prologueLineCount++
}
@@ -762,11 +741,7 @@ export async function POST(req: NextRequest) {
prologue += `environmentVariables = json.loads(${JSON.stringify(JSON.stringify(envVars))})\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
if (v === undefined) {
prologue += `${k} = None\n`
} else {
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
}
prologue += `${k} = json.loads(${JSON.stringify(JSON.stringify(v))})\n`
prologueLineCount++
}
const wrapped = [

View File

@@ -157,7 +157,7 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
enabledFilter: undefined,
includeDisabled: false,
search: undefined,
limit: 50,
offset: 0,
@@ -166,7 +166,7 @@ describe('Knowledge Base Documents API Route', () => {
)
})
it('should return documents with default filter', async () => {
it('should filter disabled documents by default', async () => {
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
const { getDocuments } = await import('@/lib/knowledge/documents/service')
@@ -194,7 +194,7 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
enabledFilter: undefined,
includeDisabled: false,
search: undefined,
limit: 50,
offset: 0,
@@ -203,7 +203,7 @@ describe('Knowledge Base Documents API Route', () => {
)
})
it('should filter documents by enabled status when requested', async () => {
it('should include disabled documents when requested', async () => {
const { checkKnowledgeBaseAccess } = await import('@/app/api/knowledge/utils')
const { getDocuments } = await import('@/lib/knowledge/documents/service')
@@ -223,7 +223,7 @@ describe('Knowledge Base Documents API Route', () => {
},
})
const url = 'http://localhost:3000/api/knowledge/kb-123/documents?enabledFilter=disabled'
const url = 'http://localhost:3000/api/knowledge/kb-123/documents?includeDisabled=true'
const req = new Request(url, { method: 'GET' }) as any
const { GET } = await import('@/app/api/knowledge/[id]/documents/route')
@@ -233,7 +233,7 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(getDocuments)).toHaveBeenCalledWith(
'kb-123',
{
enabledFilter: 'disabled',
includeDisabled: true,
search: undefined,
limit: 50,
offset: 0,
@@ -361,7 +361,8 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(createSingleDocument)).toHaveBeenCalledWith(
validDocumentData,
'kb-123',
expect.any(String)
expect.any(String),
'user-123'
)
})
@@ -469,7 +470,8 @@ describe('Knowledge Base Documents API Route', () => {
expect(vi.mocked(createDocumentRecords)).toHaveBeenCalledWith(
validBulkData.documents,
'kb-123',
expect.any(String)
expect.any(String),
'user-123'
)
expect(vi.mocked(processDocumentsWithQueue)).toHaveBeenCalled()
})

View File

@@ -5,7 +5,6 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import {
bulkDocumentOperation,
bulkDocumentOperationByFilter,
createDocumentRecords,
createSingleDocument,
getDocuments,
@@ -58,20 +57,13 @@ const BulkCreateDocumentsSchema = z.object({
bulk: z.literal(true),
})
const BulkUpdateDocumentsSchema = z
.object({
operation: z.enum(['enable', 'disable', 'delete']),
documentIds: z
.array(z.string())
.min(1, 'At least one document ID is required')
.max(100, 'Cannot operate on more than 100 documents at once')
.optional(),
selectAll: z.boolean().optional(),
enabledFilter: z.enum(['all', 'enabled', 'disabled']).optional(),
})
.refine((data) => data.selectAll || (data.documentIds && data.documentIds.length > 0), {
message: 'Either selectAll must be true or documentIds must be provided',
})
const BulkUpdateDocumentsSchema = z.object({
operation: z.enum(['enable', 'disable', 'delete']),
documentIds: z
.array(z.string())
.min(1, 'At least one document ID is required')
.max(100, 'Cannot operate on more than 100 documents at once'),
})
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = randomUUID().slice(0, 8)
@@ -98,17 +90,14 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
}
const url = new URL(req.url)
const enabledFilter = url.searchParams.get('enabledFilter') as
| 'all'
| 'enabled'
| 'disabled'
| null
const includeDisabled = url.searchParams.get('includeDisabled') === 'true'
const search = url.searchParams.get('search') || undefined
const limit = Number.parseInt(url.searchParams.get('limit') || '50')
const offset = Number.parseInt(url.searchParams.get('offset') || '0')
const sortByParam = url.searchParams.get('sortBy')
const sortOrderParam = url.searchParams.get('sortOrder')
// Validate sort parameters
const validSortFields: DocumentSortField[] = [
'filename',
'fileSize',
@@ -116,7 +105,6 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
'chunkCount',
'uploadedAt',
'processingStatus',
'enabled',
]
const validSortOrders: SortOrder[] = ['asc', 'desc']
@@ -132,7 +120,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
const result = await getDocuments(
knowledgeBaseId,
{
enabledFilter: enabledFilter || undefined,
includeDisabled,
search,
limit,
offset,
@@ -202,7 +190,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
const createdDocuments = await createDocumentRecords(
validatedData.documents,
knowledgeBaseId,
requestId
requestId,
userId
)
logger.info(
@@ -261,10 +250,16 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
throw validationError
}
} else {
// Handle single document creation
try {
const validatedData = CreateDocumentSchema.parse(body)
const newDocument = await createSingleDocument(validatedData, knowledgeBaseId, requestId)
const newDocument = await createSingleDocument(
validatedData,
knowledgeBaseId,
requestId,
userId
)
try {
const { PlatformEvents } = await import('@/lib/core/telemetry')
@@ -299,6 +294,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
} catch (error) {
logger.error(`[${requestId}] Error creating document`, error)
// Check if it's a storage limit error
const errorMessage = error instanceof Error ? error.message : 'Failed to create document'
const isStorageLimitError =
errorMessage.includes('Storage limit exceeded') || errorMessage.includes('storage limit')
@@ -335,22 +331,16 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
try {
const validatedData = BulkUpdateDocumentsSchema.parse(body)
const { operation, documentIds, selectAll, enabledFilter } = validatedData
const { operation, documentIds } = validatedData
try {
let result
if (selectAll) {
result = await bulkDocumentOperationByFilter(
knowledgeBaseId,
operation,
enabledFilter,
requestId
)
} else if (documentIds && documentIds.length > 0) {
result = await bulkDocumentOperation(knowledgeBaseId, operation, documentIds, requestId)
} else {
return NextResponse.json({ error: 'No documents specified' }, { status: 400 })
}
const result = await bulkDocumentOperation(
knowledgeBaseId,
operation,
documentIds,
requestId,
session.user.id
)
return NextResponse.json({
success: true,

View File

@@ -1,10 +1,11 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
import { McpClient } from '@/lib/mcp/client'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
import type { McpTransport } from '@/lib/mcp/types'
import type { McpServerConfig, McpTransport } from '@/lib/mcp/types'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
import { resolveEnvVarReferences } from '@/executor/utils/reference-validation'
const logger = createLogger('McpServerTestAPI')
@@ -18,6 +19,30 @@ function isUrlBasedTransport(transport: McpTransport): boolean {
return transport === 'streamable-http'
}
/**
* Resolve environment variables in strings
*/
function resolveEnvVars(value: string, envVars: Record<string, string>): string {
const missingVars: string[] = []
const resolvedValue = resolveEnvVarReferences(value, envVars, {
allowEmbedded: true,
resolveExactMatch: true,
trimKeys: true,
onMissing: 'keep',
deep: false,
missingKeys: missingVars,
}) as string
if (missingVars.length > 0) {
const uniqueMissing = Array.from(new Set(missingVars))
uniqueMissing.forEach((envKey) => {
logger.warn(`Environment variable "${envKey}" not found in MCP server test`)
})
}
return resolvedValue
}
interface TestConnectionRequest {
name: string
transport: McpTransport
@@ -71,30 +96,39 @@ export const POST = withMcpAuth('write')(
)
}
// Build initial config for resolution
const initialConfig = {
let resolvedUrl = body.url
let resolvedHeaders = body.headers || {}
try {
const envVars = await getEffectiveDecryptedEnv(userId, workspaceId)
if (resolvedUrl) {
resolvedUrl = resolveEnvVars(resolvedUrl, envVars)
}
const resolvedHeadersObj: Record<string, string> = {}
for (const [key, value] of Object.entries(resolvedHeaders)) {
resolvedHeadersObj[key] = resolveEnvVars(value, envVars)
}
resolvedHeaders = resolvedHeadersObj
} catch (envError) {
logger.warn(
`[${requestId}] Failed to resolve environment variables, using raw values:`,
envError
)
}
const testConfig: McpServerConfig = {
id: `test-${requestId}`,
name: body.name,
transport: body.transport,
url: body.url,
headers: body.headers || {},
url: resolvedUrl,
headers: resolvedHeaders,
timeout: body.timeout || 10000,
retries: 1, // Only one retry for tests
enabled: true,
}
// Resolve env vars using shared utility (non-strict mode for testing)
const { config: testConfig, missingVars } = await resolveMcpConfigEnvVars(
initialConfig,
userId,
workspaceId,
{ strict: false }
)
if (missingVars.length > 0) {
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
}
const testSecurityPolicy = {
requireConsent: false,
auditLevel: 'none' as const,

View File

@@ -3,9 +3,7 @@ import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { StreamingExecution } from '@/executor/types'
import { executeProviderRequest } from '@/providers'
@@ -22,11 +20,6 @@ export async function POST(request: NextRequest) {
const startTime = Date.now()
try {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
logger.info(`[${requestId}] Provider API request started`, {
timestamp: new Date().toISOString(),
userAgent: request.headers.get('User-Agent'),
@@ -92,13 +85,6 @@ export async function POST(request: NextRequest) {
verbosity,
})
if (workspaceId) {
const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId)
if (!workspaceAccess.hasAccess) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
let finalApiKey: string | undefined = apiKey
try {
if (provider === 'vertex' && vertexCredential) {

View File

@@ -0,0 +1,138 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { deleteTable, type TableSchema } from '@/lib/table'
import { accessError, checkAccess, normalizeColumn, verifyTableWorkspace } from '../utils'
const logger = createLogger('TableDetailAPI')
const GetTableSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
interface TableRouteParams {
params: Promise<{ tableId: string }>
}
/** GET /api/table/[tableId] - Retrieves a single table's details. */
export async function GET(request: NextRequest, { params }: TableRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized table access attempt`)
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const validated = GetTableSchema.parse({
workspaceId: searchParams.get('workspaceId'),
})
const result = await checkAccess(tableId, authResult.userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
logger.info(`[${requestId}] Retrieved table ${tableId} for user ${authResult.userId}`)
const schemaData = table.schema as TableSchema
return NextResponse.json({
success: true,
data: {
table: {
id: table.id,
name: table.name,
description: table.description,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
: String(table.createdAt),
updatedAt:
table.updatedAt instanceof Date
? table.updatedAt.toISOString()
: String(table.updatedAt),
},
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error getting table:`, error)
return NextResponse.json({ error: 'Failed to get table' }, { status: 500 })
}
}
/** DELETE /api/table/[tableId] - Deletes a table and all its rows. */
export async function DELETE(request: NextRequest, { params }: TableRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
logger.warn(`[${requestId}] Unauthorized table delete attempt`)
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const validated = GetTableSchema.parse({
workspaceId: searchParams.get('workspaceId'),
})
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
await deleteTable(tableId, requestId)
return NextResponse.json({
success: true,
data: {
message: 'Table deleted successfully',
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error deleting table:`, error)
return NextResponse.json({ error: 'Failed to delete table' }, { status: 500 })
}
}

View File

@@ -0,0 +1,276 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { RowData, TableSchema } from '@/lib/table'
import { validateRowData } from '@/lib/table'
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
const logger = createLogger('TableRowAPI')
const GetRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
const UpdateRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
})
const DeleteRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
interface RowRouteParams {
params: Promise<{ tableId: string; rowId: string }>
}
/** GET /api/table/[tableId]/rows/[rowId] - Retrieves a single row. */
export async function GET(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
const { tableId, rowId } = await params
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const validated = GetRowSchema.parse({
workspaceId: searchParams.get('workspaceId'),
})
const result = await checkAccess(tableId, authResult.userId, 'read')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const [row] = await db
.select({
id: userTableRows.id,
data: userTableRows.data,
createdAt: userTableRows.createdAt,
updatedAt: userTableRows.updatedAt,
})
.from(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.limit(1)
if (!row) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
logger.info(`[${requestId}] Retrieved row ${rowId} from table ${tableId}`)
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: row.data,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
},
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error getting row:`, error)
return NextResponse.json({ error: 'Failed to get row' }, { status: 500 })
}
}
/** PATCH /api/table/[tableId]/rows/[rowId] - Updates a single row (supports partial updates). */
export async function PATCH(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
const { tableId, rowId } = await params
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const validated = UpdateRowSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
// Fetch existing row to support partial updates
const [existingRow] = await db
.select({ data: userTableRows.data })
.from(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.limit(1)
if (!existingRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
// Merge existing data with incoming partial data (incoming takes precedence)
const mergedData = {
...(existingRow.data as RowData),
...(validated.data as RowData),
}
const validation = await validateRowData({
rowData: mergedData,
schema: table.schema as TableSchema,
tableId,
excludeRowId: rowId,
})
if (!validation.valid) return validation.response
const now = new Date()
const [updatedRow] = await db
.update(userTableRows)
.set({
data: mergedData,
updatedAt: now,
})
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.returning()
if (!updatedRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
logger.info(`[${requestId}] Updated row ${rowId} in table ${tableId}`)
return NextResponse.json({
success: true,
data: {
row: {
id: updatedRow.id,
data: updatedRow.data,
createdAt: updatedRow.createdAt.toISOString(),
updatedAt: updatedRow.updatedAt.toISOString(),
},
message: 'Row updated successfully',
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error updating row:`, error)
return NextResponse.json({ error: 'Failed to update row' }, { status: 500 })
}
}
/** DELETE /api/table/[tableId]/rows/[rowId] - Deletes a single row. */
export async function DELETE(request: NextRequest, { params }: RowRouteParams) {
const requestId = generateRequestId()
const { tableId, rowId } = await params
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const validated = DeleteRowSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const [deletedRow] = await db
.delete(userTableRows)
.where(
and(
eq(userTableRows.id, rowId),
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId)
)
)
.returning()
if (!deletedRow) {
return NextResponse.json({ error: 'Row not found' }, { status: 404 })
}
logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`)
return NextResponse.json({
success: true,
data: {
message: 'Row deleted successfully',
deletedCount: 1,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error deleting row:`, error)
return NextResponse.json({ error: 'Failed to delete row' }, { status: 500 })
}
}

View File

@@ -0,0 +1,681 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
import {
checkUniqueConstraintsDb,
getUniqueColumns,
TABLE_LIMITS,
USER_TABLE_ROWS_SQL_NAME,
validateBatchRows,
validateRowAgainstSchema,
validateRowData,
validateRowSize,
} from '@/lib/table'
import { buildFilterClause, buildSortClause } from '@/lib/table/sql'
import { accessError, checkAccess } from '../../utils'
const logger = createLogger('TableRowsAPI')
const InsertRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
})
const BatchInsertRowsSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
rows: z
.array(z.record(z.unknown()), { required_error: 'Rows array is required' })
.min(1, 'At least one row is required')
.max(1000, 'Cannot insert more than 1000 rows per batch'),
})
const QueryRowsSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: z.record(z.unknown()).optional(),
sort: z.record(z.enum(['asc', 'desc'])).optional(),
limit: z.coerce
.number({ required_error: 'Limit must be a number' })
.int('Limit must be an integer')
.min(1, 'Limit must be at least 1')
.max(TABLE_LIMITS.MAX_QUERY_LIMIT, `Limit cannot exceed ${TABLE_LIMITS.MAX_QUERY_LIMIT}`)
.optional()
.default(100),
offset: z.coerce
.number({ required_error: 'Offset must be a number' })
.int('Offset must be an integer')
.min(0, 'Offset must be 0 or greater')
.optional()
.default(0),
})
const UpdateRowsByFilterSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
data: z.record(z.unknown(), { required_error: 'Update data is required' }),
limit: z.coerce
.number({ required_error: 'Limit must be a number' })
.int('Limit must be an integer')
.min(1, 'Limit must be at least 1')
.max(1000, 'Cannot update more than 1000 rows per operation')
.optional(),
})
const DeleteRowsByFilterSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
filter: z.record(z.unknown(), { required_error: 'Filter criteria is required' }),
limit: z.coerce
.number({ required_error: 'Limit must be a number' })
.int('Limit must be an integer')
.min(1, 'Limit must be at least 1')
.max(1000, 'Cannot delete more than 1000 rows per operation')
.optional(),
})
interface TableRowsRouteParams {
params: Promise<{ tableId: string }>
}
async function handleBatchInsert(
requestId: string,
tableId: string,
body: z.infer<typeof BatchInsertRowsSchema>,
userId: string
): Promise<NextResponse> {
const validated = BatchInsertRowsSchema.parse(body)
const accessResult = await checkAccess(tableId, userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const workspaceId = validated.workspaceId
const remainingCapacity = table.maxRows - table.rowCount
if (remainingCapacity < validated.rows.length) {
return NextResponse.json(
{
error: `Insufficient capacity. Can only insert ${remainingCapacity} more rows (table has ${table.rowCount}/${table.maxRows} rows)`,
},
{ status: 400 }
)
}
const validation = await validateBatchRows({
rows: validated.rows as RowData[],
schema: table.schema as TableSchema,
tableId,
})
if (!validation.valid) return validation.response
const now = new Date()
const rowsToInsert = validated.rows.map((data) => ({
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
tableId,
workspaceId,
data,
createdAt: now,
updatedAt: now,
createdBy: userId,
}))
const insertedRows = await db.insert(userTableRows).values(rowsToInsert).returning()
logger.info(`[${requestId}] Batch inserted ${insertedRows.length} rows into table ${tableId}`)
return NextResponse.json({
success: true,
data: {
rows: insertedRows.map((r) => ({
id: r.id,
data: r.data,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})),
insertedCount: insertedRows.length,
message: `Successfully inserted ${insertedRows.length} rows`,
},
})
}
/** POST /api/table/[tableId]/rows - Inserts row(s). Supports single or batch insert. */
export async function POST(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
if (
typeof body === 'object' &&
body !== null &&
'rows' in body &&
Array.isArray((body as Record<string, unknown>).rows)
) {
return handleBatchInsert(
requestId,
tableId,
body as z.infer<typeof BatchInsertRowsSchema>,
authResult.userId
)
}
const validated = InsertRowSchema.parse(body)
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const workspaceId = validated.workspaceId
const rowData = validated.data as RowData
const validation = await validateRowData({
rowData,
schema: table.schema as TableSchema,
tableId,
})
if (!validation.valid) return validation.response
if (table.rowCount >= table.maxRows) {
return NextResponse.json(
{ error: `Table row limit reached (${table.maxRows} rows max)` },
{ status: 400 }
)
}
const rowId = `row_${crypto.randomUUID().replace(/-/g, '')}`
const now = new Date()
const [row] = await db
.insert(userTableRows)
.values({
id: rowId,
tableId,
workspaceId,
data: validated.data,
createdAt: now,
updatedAt: now,
createdBy: authResult.userId,
})
.returning()
logger.info(`[${requestId}] Inserted row ${rowId} into table ${tableId}`)
return NextResponse.json({
success: true,
data: {
row: {
id: row.id,
data: row.data,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
},
message: 'Row inserted successfully',
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error inserting row:`, error)
return NextResponse.json({ error: 'Failed to insert row' }, { status: 500 })
}
}
/** GET /api/table/[tableId]/rows - Queries rows with filtering, sorting, and pagination. */
export async function GET(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
const filterParam = searchParams.get('filter')
const sortParam = searchParams.get('sort')
const limit = searchParams.get('limit')
const offset = searchParams.get('offset')
let filter: Record<string, unknown> | undefined
let sort: Sort | undefined
try {
if (filterParam) {
filter = JSON.parse(filterParam) as Record<string, unknown>
}
if (sortParam) {
sort = JSON.parse(sortParam) as Sort
}
} catch {
return NextResponse.json({ error: 'Invalid filter or sort JSON' }, { status: 400 })
}
const validated = QueryRowsSchema.parse({
workspaceId,
filter,
sort,
limit,
offset,
})
const accessResult = await checkAccess(tableId, authResult.userId, 'read')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const baseConditions = [
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
]
if (validated.filter) {
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
if (filterClause) {
baseConditions.push(filterClause)
}
}
let query = db
.select({
id: userTableRows.id,
data: userTableRows.data,
createdAt: userTableRows.createdAt,
updatedAt: userTableRows.updatedAt,
})
.from(userTableRows)
.where(and(...baseConditions))
if (validated.sort) {
const schema = table.schema as TableSchema
const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns)
if (sortClause) {
query = query.orderBy(sortClause) as typeof query
}
} else {
query = query.orderBy(userTableRows.createdAt) as typeof query
}
const countQuery = db
.select({ count: sql<number>`count(*)` })
.from(userTableRows)
.where(and(...baseConditions))
const [{ count: totalCount }] = await countQuery
const rows = await query.limit(validated.limit).offset(validated.offset)
logger.info(
`[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount})`
)
return NextResponse.json({
success: true,
data: {
rows: rows.map((r) => ({
id: r.id,
data: r.data,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})),
rowCount: rows.length,
totalCount: Number(totalCount),
limit: validated.limit,
offset: validated.offset,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error querying rows:`, error)
return NextResponse.json({ error: 'Failed to query rows' }, { status: 500 })
}
}
/** PUT /api/table/[tableId]/rows - Updates rows matching filter criteria. */
export async function PUT(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const validated = UpdateRowsByFilterSchema.parse(body)
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const updateData = validated.data as RowData
const sizeValidation = validateRowSize(updateData)
if (!sizeValidation.valid) {
return NextResponse.json(
{ error: 'Invalid row data', details: sizeValidation.errors },
{ status: 400 }
)
}
const baseConditions = [
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
]
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
if (filterClause) {
baseConditions.push(filterClause)
}
let matchingRowsQuery = db
.select({
id: userTableRows.id,
data: userTableRows.data,
})
.from(userTableRows)
.where(and(...baseConditions))
if (validated.limit) {
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
}
const matchingRows = await matchingRowsQuery
if (matchingRows.length === 0) {
return NextResponse.json(
{
success: true,
data: {
message: 'No rows matched the filter criteria',
updatedCount: 0,
},
},
{ status: 200 }
)
}
if (matchingRows.length > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) {
logger.warn(`[${requestId}] Updating ${matchingRows.length} rows. This may take some time.`)
}
for (const row of matchingRows) {
const existingData = row.data as RowData
const mergedData = { ...existingData, ...updateData }
const rowValidation = validateRowAgainstSchema(mergedData, table.schema as TableSchema)
if (!rowValidation.valid) {
return NextResponse.json(
{
error: 'Updated data does not match schema',
details: rowValidation.errors,
affectedRowId: row.id,
},
{ status: 400 }
)
}
}
const uniqueColumns = getUniqueColumns(table.schema as TableSchema)
if (uniqueColumns.length > 0) {
// If updating multiple rows, check that updateData doesn't set any unique column
// (would cause all rows to have the same value, violating uniqueness)
if (matchingRows.length > 1) {
const uniqueColumnsInUpdate = uniqueColumns.filter((col) => col.name in updateData)
if (uniqueColumnsInUpdate.length > 0) {
return NextResponse.json(
{
error: 'Cannot set unique column values when updating multiple rows',
details: [
`Columns with unique constraint: ${uniqueColumnsInUpdate.map((c) => c.name).join(', ')}. ` +
`Updating ${matchingRows.length} rows with the same value would violate uniqueness.`,
],
},
{ status: 400 }
)
}
}
// Check unique constraints against database for each row
for (const row of matchingRows) {
const existingData = row.data as RowData
const mergedData = { ...existingData, ...updateData }
const uniqueValidation = await checkUniqueConstraintsDb(
tableId,
mergedData,
table.schema as TableSchema,
row.id
)
if (!uniqueValidation.valid) {
return NextResponse.json(
{
error: 'Unique constraint violation',
details: uniqueValidation.errors,
affectedRowId: row.id,
},
{ status: 400 }
)
}
}
}
const now = new Date()
await db.transaction(async (trx) => {
let totalUpdated = 0
for (let i = 0; i < matchingRows.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) {
const batch = matchingRows.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE)
const updatePromises = batch.map((row) => {
const existingData = row.data as RowData
return trx
.update(userTableRows)
.set({
data: { ...existingData, ...updateData },
updatedAt: now,
})
.where(eq(userTableRows.id, row.id))
})
await Promise.all(updatePromises)
totalUpdated += batch.length
logger.info(
`[${requestId}] Updated batch ${Math.floor(i / TABLE_LIMITS.UPDATE_BATCH_SIZE) + 1} (${totalUpdated}/${matchingRows.length} rows)`
)
}
})
logger.info(`[${requestId}] Updated ${matchingRows.length} rows in table ${tableId}`)
return NextResponse.json({
success: true,
data: {
message: 'Rows updated successfully',
updatedCount: matchingRows.length,
updatedRowIds: matchingRows.map((r) => r.id),
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error updating rows by filter:`, error)
const errorMessage = error instanceof Error ? error.message : String(error)
const detailedError = `Failed to update rows: ${errorMessage}`
return NextResponse.json({ error: detailedError }, { status: 500 })
}
}
/** DELETE /api/table/[tableId]/rows - Deletes rows matching filter criteria. */
export async function DELETE(request: NextRequest, { params }: TableRowsRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const validated = DeleteRowsByFilterSchema.parse(body)
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
if (!accessResult.ok) return accessError(accessResult, requestId, tableId)
const { table } = accessResult
if (validated.workspaceId !== table.workspaceId) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const baseConditions = [
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
]
const filterClause = buildFilterClause(validated.filter as Filter, USER_TABLE_ROWS_SQL_NAME)
if (filterClause) {
baseConditions.push(filterClause)
}
let matchingRowsQuery = db
.select({ id: userTableRows.id })
.from(userTableRows)
.where(and(...baseConditions))
if (validated.limit) {
matchingRowsQuery = matchingRowsQuery.limit(validated.limit) as typeof matchingRowsQuery
}
const matchingRows = await matchingRowsQuery
if (matchingRows.length === 0) {
return NextResponse.json(
{
success: true,
data: {
message: 'No rows matched the filter criteria',
deletedCount: 0,
},
},
{ status: 200 }
)
}
if (matchingRows.length > TABLE_LIMITS.DELETE_BATCH_SIZE) {
logger.warn(`[${requestId}] Deleting ${matchingRows.length} rows. This may take some time.`)
}
const rowIds = matchingRows.map((r) => r.id)
await db.transaction(async (trx) => {
let totalDeleted = 0
for (let i = 0; i < rowIds.length; i += TABLE_LIMITS.DELETE_BATCH_SIZE) {
const batch = rowIds.slice(i, i + TABLE_LIMITS.DELETE_BATCH_SIZE)
await trx.delete(userTableRows).where(
and(
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
sql`${userTableRows.id} = ANY(ARRAY[${sql.join(
batch.map((id) => sql`${id}`),
sql`, `
)}])`
)
)
totalDeleted += batch.length
logger.info(
`[${requestId}] Deleted batch ${Math.floor(i / TABLE_LIMITS.DELETE_BATCH_SIZE) + 1} (${totalDeleted}/${rowIds.length} rows)`
)
}
})
logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${tableId}`)
return NextResponse.json({
success: true,
data: {
message: 'Rows deleted successfully',
deletedCount: matchingRows.length,
deletedRowIds: rowIds,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error deleting rows by filter:`, error)
const errorMessage = error instanceof Error ? error.message : String(error)
const detailedError = `Failed to delete rows: ${errorMessage}`
return NextResponse.json({ error: detailedError }, { status: 500 })
}
}

View File

@@ -0,0 +1,182 @@
import { db } from '@sim/db'
import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import type { RowData, TableSchema } from '@/lib/table'
import { getUniqueColumns, validateRowData } from '@/lib/table'
import { accessError, checkAccess, verifyTableWorkspace } from '../../../utils'
const logger = createLogger('TableUpsertAPI')
const UpsertRowSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
data: z.record(z.unknown(), { required_error: 'Row data is required' }),
})
interface UpsertRouteParams {
params: Promise<{ tableId: string }>
}
/** POST /api/table/[tableId]/rows/upsert - Inserts or updates based on unique columns. */
export async function POST(request: NextRequest, { params }: UpsertRouteParams) {
const requestId = generateRequestId()
const { tableId } = await params
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const validated = UpsertRowSchema.parse(body)
const result = await checkAccess(tableId, authResult.userId, 'write')
if (!result.ok) return accessError(result, requestId, tableId)
const { table } = result
const isValidWorkspace = await verifyTableWorkspace(tableId, validated.workspaceId)
if (!isValidWorkspace) {
logger.warn(
`[${requestId}] Workspace ID mismatch for table ${tableId}. Provided: ${validated.workspaceId}, Actual: ${table.workspaceId}`
)
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
const schema = table.schema as TableSchema
const rowData = validated.data as RowData
const validation = await validateRowData({
rowData,
schema,
tableId,
checkUnique: false,
})
if (!validation.valid) return validation.response
const uniqueColumns = getUniqueColumns(schema)
if (uniqueColumns.length === 0) {
return NextResponse.json(
{
error:
'Upsert requires at least one unique column in the schema. Please add a unique constraint to a column or use insert instead.',
},
{ status: 400 }
)
}
const uniqueFilters = uniqueColumns.map((col) => {
const value = rowData[col.name]
if (value === undefined || value === null) {
return null
}
return sql`${userTableRows.data}->>${col.name} = ${String(value)}`
})
const validUniqueFilters = uniqueFilters.filter((f): f is Exclude<typeof f, null> => f !== null)
if (validUniqueFilters.length === 0) {
return NextResponse.json(
{
error: `Upsert requires values for at least one unique field: ${uniqueColumns.map((c) => c.name).join(', ')}`,
},
{ status: 400 }
)
}
const [existingRow] = await db
.select()
.from(userTableRows)
.where(
and(
eq(userTableRows.tableId, tableId),
eq(userTableRows.workspaceId, validated.workspaceId),
or(...validUniqueFilters)
)
)
.limit(1)
const now = new Date()
if (!existingRow && table.rowCount >= table.maxRows) {
return NextResponse.json(
{ error: `Table row limit reached (${table.maxRows} rows max)` },
{ status: 400 }
)
}
const upsertResult = await db.transaction(async (trx) => {
if (existingRow) {
const [updatedRow] = await trx
.update(userTableRows)
.set({
data: validated.data,
updatedAt: now,
})
.where(eq(userTableRows.id, existingRow.id))
.returning()
return {
row: updatedRow,
operation: 'update' as const,
}
}
const [insertedRow] = await trx
.insert(userTableRows)
.values({
id: `row_${crypto.randomUUID().replace(/-/g, '')}`,
tableId,
workspaceId: validated.workspaceId,
data: validated.data,
createdAt: now,
updatedAt: now,
createdBy: authResult.userId,
})
.returning()
return {
row: insertedRow,
operation: 'insert' as const,
}
})
logger.info(
`[${requestId}] Upserted (${upsertResult.operation}) row ${upsertResult.row.id} in table ${tableId}`
)
return NextResponse.json({
success: true,
data: {
row: {
id: upsertResult.row.id,
data: upsertResult.row.data,
createdAt: upsertResult.row.createdAt.toISOString(),
updatedAt: upsertResult.row.updatedAt.toISOString(),
},
operation: upsertResult.operation,
message: `Row ${upsertResult.operation === 'update' ? 'updated' : 'inserted'} successfully`,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error upserting row:`, error)
const errorMessage = error instanceof Error ? error.message : String(error)
const detailedError = `Failed to upsert row: ${errorMessage}`
return NextResponse.json({ error: detailedError }, { status: 500 })
}
}

View File

@@ -0,0 +1,293 @@
import { db } from '@sim/db'
import { permissions, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
canCreateTable,
createTable,
getWorkspaceTableLimits,
listTables,
TABLE_LIMITS,
type TableSchema,
} from '@/lib/table'
import { normalizeColumn } from './utils'
const logger = createLogger('TableAPI')
const ColumnSchema = z.object({
name: z
.string()
.min(1, 'Column name is required')
.max(
TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH,
`Column name must be ${TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH} characters or less`
)
.regex(
/^[a-z_][a-z0-9_]*$/i,
'Column name must start with a letter or underscore and contain only alphanumeric characters and underscores'
),
type: z.enum(['string', 'number', 'boolean', 'date', 'json'], {
errorMap: () => ({
message: 'Column type must be one of: string, number, boolean, date, json',
}),
}),
required: z.boolean().optional().default(false),
unique: z.boolean().optional().default(false),
})
const CreateTableSchema = z.object({
name: z
.string()
.min(1, 'Table name is required')
.max(
TABLE_LIMITS.MAX_TABLE_NAME_LENGTH,
`Table name must be ${TABLE_LIMITS.MAX_TABLE_NAME_LENGTH} characters or less`
)
.regex(
/^[a-z_][a-z0-9_]*$/i,
'Table name must start with a letter or underscore and contain only alphanumeric characters and underscores'
),
description: z
.string()
.max(
TABLE_LIMITS.MAX_DESCRIPTION_LENGTH,
`Description must be ${TABLE_LIMITS.MAX_DESCRIPTION_LENGTH} characters or less`
)
.optional(),
schema: z.object({
columns: z
.array(ColumnSchema)
.min(1, 'Table must have at least one column')
.max(
TABLE_LIMITS.MAX_COLUMNS_PER_TABLE,
`Table cannot have more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns`
),
}),
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
const ListTablesSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
})
interface WorkspaceAccessResult {
hasAccess: boolean
canWrite: boolean
}
async function checkWorkspaceAccess(
workspaceId: string,
userId: string
): Promise<WorkspaceAccessResult> {
const [workspaceData] = await db
.select({
id: workspace.id,
ownerId: workspace.ownerId,
})
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
if (!workspaceData) {
return { hasAccess: false, canWrite: false }
}
if (workspaceData.ownerId === userId) {
return { hasAccess: true, canWrite: true }
}
const [permission] = await db
.select({
permissionType: permissions.permissionType,
})
.from(permissions)
.where(
and(
eq(permissions.userId, userId),
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workspaceId)
)
)
.limit(1)
if (!permission) {
return { hasAccess: false, canWrite: false }
}
const canWrite = permission.permissionType === 'admin' || permission.permissionType === 'write'
return {
hasAccess: true,
canWrite,
}
}
/** POST /api/table - Creates a new user-defined table. */
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const body: unknown = await request.json()
const params = CreateTableSchema.parse(body)
const { hasAccess, canWrite } = await checkWorkspaceAccess(
params.workspaceId,
authResult.userId
)
if (!hasAccess || !canWrite) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Check billing plan limits
const existingTables = await listTables(params.workspaceId)
const { canCreate, maxTables } = await canCreateTable(params.workspaceId, existingTables.length)
if (!canCreate) {
return NextResponse.json(
{
error: `Workspace has reached the maximum table limit (${maxTables}) for your plan. Please upgrade to create more tables.`,
},
{ status: 403 }
)
}
// Get plan-based row limits
const planLimits = await getWorkspaceTableLimits(params.workspaceId)
const maxRowsPerTable = planLimits.maxRowsPerTable
const normalizedSchema: TableSchema = {
columns: params.schema.columns.map(normalizeColumn),
}
const table = await createTable(
{
name: params.name,
description: params.description,
schema: normalizedSchema,
workspaceId: params.workspaceId,
userId: authResult.userId,
maxRows: maxRowsPerTable,
},
requestId
)
return NextResponse.json({
success: true,
data: {
table: {
id: table.id,
name: table.name,
description: table.description,
schema: table.schema,
rowCount: table.rowCount,
maxRows: table.maxRows,
createdAt:
table.createdAt instanceof Date
? table.createdAt.toISOString()
: String(table.createdAt),
updatedAt:
table.updatedAt instanceof Date
? table.updatedAt.toISOString()
: String(table.updatedAt),
},
message: 'Table created successfully',
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
if (error instanceof Error) {
if (
error.message.includes('Invalid table name') ||
error.message.includes('Invalid schema') ||
error.message.includes('already exists') ||
error.message.includes('maximum table limit')
) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
}
logger.error(`[${requestId}] Error creating table:`, error)
return NextResponse.json({ error: 'Failed to create table' }, { status: 500 })
}
}
/** GET /api/table - Lists all tables in a workspace. */
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const authResult = await checkHybridAuth(request)
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const workspaceId = searchParams.get('workspaceId')
const validation = ListTablesSchema.safeParse({ workspaceId })
if (!validation.success) {
return NextResponse.json(
{ error: 'Validation error', details: validation.error.errors },
{ status: 400 }
)
}
const params = validation.data
const { hasAccess } = await checkWorkspaceAccess(params.workspaceId, authResult.userId)
if (!hasAccess) {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const tables = await listTables(params.workspaceId)
logger.info(`[${requestId}] Listed ${tables.length} tables in workspace ${params.workspaceId}`)
return NextResponse.json({
success: true,
data: {
tables: tables.map((t) => {
const schemaData = t.schema as TableSchema
return {
...t,
schema: {
columns: schemaData.columns.map(normalizeColumn),
},
createdAt:
t.createdAt instanceof Date ? t.createdAt.toISOString() : String(t.createdAt),
updatedAt:
t.updatedAt instanceof Date ? t.updatedAt.toISOString() : String(t.updatedAt),
}
}),
totalCount: tables.length,
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Validation error', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error listing tables:`, error)
return NextResponse.json({ error: 'Failed to list tables' }, { status: 500 })
}
}

View File

@@ -0,0 +1,188 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import type { ColumnDefinition, TableDefinition } from '@/lib/table'
import { getTableById } from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('TableUtils')
export interface TableAccessResult {
hasAccess: true
table: TableDefinition
}
export interface TableAccessDenied {
hasAccess: false
notFound?: boolean
reason?: string
}
export type TableAccessCheck = TableAccessResult | TableAccessDenied
export type AccessResult = { ok: true; table: TableDefinition } | { ok: false; status: 404 | 403 }
export interface ApiErrorResponse {
error: string
details?: unknown
}
/**
* Check if a user has read access to a table.
* Read access is granted if:
* 1. User created the table, OR
* 2. User has any permission on the table's workspace (read, write, or admin)
*
* Follows the same pattern as Knowledge Base access checks.
*/
export async function checkTableAccess(tableId: string, userId: string): Promise<TableAccessCheck> {
const table = await getTableById(tableId)
if (!table) {
return { hasAccess: false, notFound: true }
}
// Case 1: User created the table
if (table.createdBy === userId) {
return { hasAccess: true, table }
}
// Case 2: Table belongs to a workspace the user has permissions for
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
if (userPermission !== null) {
return { hasAccess: true, table }
}
return { hasAccess: false, reason: 'User does not have access to this table' }
}
/**
* Check if a user has write access to a table.
* Write access is granted if:
* 1. User created the table, OR
* 2. User has write or admin permissions on the table's workspace
*
* Follows the same pattern as Knowledge Base write access checks.
*/
export async function checkTableWriteAccess(
tableId: string,
userId: string
): Promise<TableAccessCheck> {
const table = await getTableById(tableId)
if (!table) {
return { hasAccess: false, notFound: true }
}
// Case 1: User created the table
if (table.createdBy === userId) {
return { hasAccess: true, table }
}
// Case 2: Table belongs to a workspace and user has write/admin permissions
const userPermission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
if (userPermission === 'write' || userPermission === 'admin') {
return { hasAccess: true, table }
}
return { hasAccess: false, reason: 'User does not have write access to this table' }
}
/**
* @deprecated Use checkTableAccess or checkTableWriteAccess instead.
* Legacy access check function for backwards compatibility.
*/
export async function checkAccess(
tableId: string,
userId: string,
level: 'read' | 'write' | 'admin' = 'read'
): Promise<AccessResult> {
const table = await getTableById(tableId)
if (!table) {
return { ok: false, status: 404 }
}
if (table.createdBy === userId) {
return { ok: true, table }
}
const permission = await getUserEntityPermissions(userId, 'workspace', table.workspaceId)
const hasAccess =
permission !== null &&
(level === 'read' ||
(level === 'write' && (permission === 'write' || permission === 'admin')) ||
(level === 'admin' && permission === 'admin'))
return hasAccess ? { ok: true, table } : { ok: false, status: 403 }
}
export function accessError(
result: { ok: false; status: 404 | 403 },
requestId: string,
context?: string
): NextResponse {
const message = result.status === 404 ? 'Table not found' : 'Access denied'
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
return NextResponse.json({ error: message }, { status: result.status })
}
/**
* Converts a TableAccessDenied result to an appropriate HTTP response.
* Use with checkTableAccess or checkTableWriteAccess.
*/
export function tableAccessError(
result: TableAccessDenied,
requestId: string,
context?: string
): NextResponse {
const status = result.notFound ? 404 : 403
const message = result.notFound ? 'Table not found' : (result.reason ?? 'Access denied')
logger.warn(`[${requestId}] ${message}${context ? `: ${context}` : ''}`)
return NextResponse.json({ error: message }, { status })
}
export async function verifyTableWorkspace(tableId: string, workspaceId: string): Promise<boolean> {
const table = await getTableById(tableId)
return table?.workspaceId === workspaceId
}
export function errorResponse(
message: string,
status: number,
details?: unknown
): NextResponse<ApiErrorResponse> {
const body: ApiErrorResponse = { error: message }
if (details !== undefined) {
body.details = details
}
return NextResponse.json(body, { status })
}
export function badRequestResponse(message: string, details?: unknown) {
return errorResponse(message, 400, details)
}
export function unauthorizedResponse(message = 'Authentication required') {
return errorResponse(message, 401)
}
export function forbiddenResponse(message = 'Access denied') {
return errorResponse(message, 403)
}
export function notFoundResponse(message = 'Resource not found') {
return errorResponse(message, 404)
}
export function serverErrorResponse(message = 'Internal server error') {
return errorResponse(message, 500)
}
export function normalizeColumn(col: ColumnDefinition): ColumnDefinition {
return {
name: col.name,
type: col.type,
required: col.required ?? false,
unique: col.unique ?? false,
}
}

View File

@@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createA2AClient } from '@/lib/a2a/utils'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { validateExternalUrl } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
export const dynamic = 'force-dynamic'
@@ -40,18 +39,6 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const validatedData = A2ASetPushNotificationSchema.parse(body)
const urlValidation = validateExternalUrl(validatedData.webhookUrl, 'Webhook URL')
if (!urlValidation.isValid) {
logger.warn(`[${requestId}] Invalid webhook URL`, { error: urlValidation.error })
return NextResponse.json(
{
success: false,
error: urlValidation.error,
},
{ status: 400 }
)
}
logger.info(`[${requestId}] A2A set push notification request`, {
agentUrl: validatedData.agentUrl,
taskId: validatedData.taskId,

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { buildDeleteQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLDeleteAPI')
@@ -22,12 +21,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL delete attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = DeleteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLExecuteAPI')
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL execute attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ExecuteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { buildInsertQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLInsertAPI')
@@ -43,12 +42,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL insert attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = InsertSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createMySQLConnection, executeIntrospect } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLIntrospectAPI')
@@ -20,12 +19,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL introspect attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = IntrospectSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createMySQLConnection, executeQuery, validateQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLQueryAPI')
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL query attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = QuerySchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { buildUpdateQuery, createMySQLConnection, executeQuery } from '@/app/api/tools/mysql/utils'
const logger = createLogger('MySQLUpdateAPI')
@@ -41,12 +40,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MySQL update attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = UpdateSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createPostgresConnection, executeDelete } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLDeleteAPI')
@@ -22,12 +21,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL delete attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = DeleteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createPostgresConnection,
executeQuery,
@@ -25,12 +24,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL execute attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ExecuteSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createPostgresConnection, executeInsert } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLInsertAPI')
@@ -43,12 +42,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL insert attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = InsertSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createPostgresConnection, executeIntrospect } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLIntrospectAPI')
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL introspect attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = IntrospectSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createPostgresConnection, executeQuery } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLQueryAPI')
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL query attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = QuerySchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createPostgresConnection, executeUpdate } from '@/app/api/tools/postgresql/utils'
const logger = createLogger('PostgreSQLUpdateAPI')
@@ -41,12 +40,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized PostgreSQL update attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = UpdateSchema.parse(body)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHCheckCommandExistsAPI')
@@ -21,12 +20,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH check command exists attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = CheckCommandExistsSchema.parse(body)

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, SFTPWrapper, Stats } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createSSHConnection,
getFileType,
@@ -40,15 +39,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH check file exists attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = CheckFileExistsSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createSSHConnection,
escapeShellArg,
@@ -28,15 +27,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH create directory attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = CreateDirectorySchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
@@ -59,6 +53,7 @@ export async function POST(request: NextRequest) {
const dirPath = sanitizePath(params.path)
const escapedPath = escapeShellArg(dirPath)
// Check if directory already exists
const checkResult = await executeSSHCommand(
client,
`test -d '${escapedPath}' && echo "exists"`
@@ -75,6 +70,7 @@ export async function POST(request: NextRequest) {
})
}
// Create directory
const mkdirFlag = params.recursive ? '-p' : ''
const command = `mkdir ${mkdirFlag} -m ${params.permissions} '${escapedPath}'`
const result = await executeSSHCommand(client, command)

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createSSHConnection,
escapeShellArg,
@@ -28,15 +27,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH delete file attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = DeleteFileSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
@@ -59,6 +53,7 @@ export async function POST(request: NextRequest) {
const filePath = sanitizePath(params.path)
const escapedPath = escapeShellArg(filePath)
// Check if path exists
const checkResult = await executeSSHCommand(
client,
`test -e '${escapedPath}' && echo "exists"`
@@ -67,6 +62,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: `Path does not exist: ${filePath}` }, { status: 404 })
}
// Build delete command
let command: string
if (params.recursive) {
command = params.force ? `rm -rf '${escapedPath}'` : `rm -r '${escapedPath}'`

View File

@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHDownloadFileAPI')
@@ -35,15 +34,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH download file attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = DownloadFileSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, executeSSHCommand, sanitizeCommand } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHExecuteCommandAPI')
@@ -22,15 +21,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH execute command attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ExecuteCommandSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
@@ -50,6 +44,7 @@ export async function POST(request: NextRequest) {
})
try {
// Build command with optional working directory
let command = sanitizeCommand(params.command)
if (params.workingDirectory) {
command = `cd "${params.workingDirectory}" && ${command}`

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, escapeShellArg, executeSSHCommand } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHExecuteScriptAPI')
@@ -23,15 +22,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH execute script attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ExecuteScriptSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },
@@ -51,10 +45,13 @@ export async function POST(request: NextRequest) {
})
try {
// Create a temporary script file, execute it, and clean up
const scriptPath = `/tmp/sim_script_${requestId}.sh`
const escapedScriptPath = escapeShellArg(scriptPath)
const escapedInterpreter = escapeShellArg(params.interpreter)
// Build the command to create, execute, and clean up the script
// Note: heredoc with quoted delimiter ('SIMEOF') prevents variable expansion
let command = `cat > '${escapedScriptPath}' << 'SIMEOF'
${params.script}
SIMEOF

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, executeSSHCommand } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHGetSystemInfoAPI')
@@ -20,15 +19,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH get system info attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = GetSystemInfoSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, FileEntry, SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createSSHConnection,
getFileType,
@@ -61,15 +60,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH list directory attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ListDirectorySchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import {
createSSHConnection,
escapeShellArg,
@@ -28,16 +27,9 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH move/rename attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = MoveRenameSchema.parse(body)
// Validate SSH authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHReadFileContentAPI')
@@ -36,12 +35,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH read file content attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = ReadFileContentSchema.parse(body)

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHUploadFileAPI')
@@ -38,12 +37,6 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH upload file attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = UploadFileSchema.parse(body)

View File

@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import type { Client, SFTPWrapper } from 'ssh2'
import { z } from 'zod'
import { checkHybridAuth } from '@/lib/auth/hybrid'
import { createSSHConnection, sanitizePath } from '@/app/api/tools/ssh/utils'
const logger = createLogger('SSHWriteFileContentAPI')
@@ -37,15 +36,10 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const auth = await checkHybridAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized SSH write file content attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const params = WriteFileContentSchema.parse(body)
// Validate authentication
if (!params.password && !params.privateKey) {
return NextResponse.json(
{ error: 'Either password or privateKey must be provided' },

View File

@@ -1,211 +0,0 @@
/**
* POST /api/v1/admin/credits
*
* Issue credits to a user by user ID or email.
*
* Body:
* - userId?: string - The user ID to issue credits to
* - email?: string - The user email to issue credits to (alternative to userId)
* - amount: number - The amount of credits to issue (in dollars)
* - reason?: string - Reason for issuing credits (for audit logging)
*
* Response: AdminSingleResponse<{
* success: true,
* entityType: 'user' | 'organization',
* entityId: string,
* amount: number,
* newCreditBalance: number,
* newUsageLimit: number,
* }>
*
* For Pro users: credits are added to user_stats.credit_balance
* For Team users: credits are added to organization.credit_balance
* Usage limits are updated accordingly to allow spending the credits.
*/
import { db } from '@sim/db'
import { organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { addCredits } from '@/lib/billing/credits/balance'
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
import { getEffectiveSeats } from '@/lib/billing/subscriptions/utils'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
internalErrorResponse,
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
const logger = createLogger('AdminCreditsAPI')
export const POST = withAdminAuth(async (request) => {
try {
const body = await request.json()
const { userId, email, amount, reason } = body
if (!userId && !email) {
return badRequestResponse('Either userId or email is required')
}
if (userId && typeof userId !== 'string') {
return badRequestResponse('userId must be a string')
}
if (email && typeof email !== 'string') {
return badRequestResponse('email must be a string')
}
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
return badRequestResponse('amount must be a positive number')
}
let resolvedUserId: string
let userEmail: string | null = null
if (userId) {
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!userData) {
return notFoundResponse('User')
}
resolvedUserId = userData.id
userEmail = userData.email
} else {
const normalizedEmail = email.toLowerCase().trim()
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
.where(eq(user.email, normalizedEmail))
.limit(1)
if (!userData) {
return notFoundResponse('User with email')
}
resolvedUserId = userData.id
userEmail = userData.email
}
const userSubscription = await getHighestPrioritySubscription(resolvedUserId)
if (!userSubscription || !['pro', 'team', 'enterprise'].includes(userSubscription.plan)) {
return badRequestResponse(
'User must have an active Pro, Team, or Enterprise subscription to receive credits'
)
}
let entityType: 'user' | 'organization'
let entityId: string
const plan = userSubscription.plan
let seats: number | null = null
if (plan === 'team' || plan === 'enterprise') {
entityType = 'organization'
entityId = userSubscription.referenceId
const [orgExists] = await db
.select({ id: organization.id })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
if (!orgExists) {
return notFoundResponse('Organization')
}
const [subData] = await db
.select()
.from(subscription)
.where(and(eq(subscription.referenceId, entityId), eq(subscription.status, 'active')))
.limit(1)
seats = getEffectiveSeats(subData)
} else {
entityType = 'user'
entityId = resolvedUserId
const [existingStats] = await db
.select({ id: userStats.id })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
if (!existingStats) {
await db.insert(userStats).values({
id: nanoid(),
userId: entityId,
})
}
}
await addCredits(entityType, entityId, amount)
let newCreditBalance: number
if (entityType === 'organization') {
const [orgData] = await db
.select({ creditBalance: organization.creditBalance })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
newCreditBalance = Number.parseFloat(orgData?.creditBalance || '0')
} else {
const [stats] = await db
.select({ creditBalance: userStats.creditBalance })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
newCreditBalance = Number.parseFloat(stats?.creditBalance || '0')
}
await setUsageLimitForCredits(entityType, entityId, plan, seats, newCreditBalance)
let newUsageLimit: number
if (entityType === 'organization') {
const [orgData] = await db
.select({ orgUsageLimit: organization.orgUsageLimit })
.from(organization)
.where(eq(organization.id, entityId))
.limit(1)
newUsageLimit = Number.parseFloat(orgData?.orgUsageLimit || '0')
} else {
const [stats] = await db
.select({ currentUsageLimit: userStats.currentUsageLimit })
.from(userStats)
.where(eq(userStats.userId, entityId))
.limit(1)
newUsageLimit = Number.parseFloat(stats?.currentUsageLimit || '0')
}
logger.info('Admin API: Issued credits', {
resolvedUserId,
userEmail,
entityType,
entityId,
amount,
newCreditBalance,
newUsageLimit,
reason: reason || 'No reason provided',
})
return singleResponse({
success: true,
userId: resolvedUserId,
userEmail,
entityType,
entityId,
amount,
newCreditBalance,
newUsageLimit,
})
} catch (error) {
logger.error('Admin API: Failed to issue credits', { error })
return internalErrorResponse('Failed to issue credits')
}
})

View File

@@ -63,9 +63,6 @@
* GET /api/v1/admin/subscriptions/:id - Get subscription details
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
*
* Credits:
* POST /api/v1/admin/credits - Issue credits to user (by userId or email)
*
* Access Control (Permission Groups):
* GET /api/v1/admin/access-control - List permission groups (?organizationId=X)
* DELETE /api/v1/admin/access-control - Delete permission groups for org (?organizationId=X)

View File

@@ -640,7 +640,6 @@ export interface AdminDeployResult {
isDeployed: boolean
version: number
deployedAt: string
warnings?: string[]
}
export interface AdminUndeployResult {

View File

@@ -1,23 +1,14 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { eq } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
cleanupWebhooksForWorkflow,
restorePreviousVersionWebhooks,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import { cleanupWebhooksForWorkflow } from '@/lib/webhooks/deploy'
import {
deployWorkflow,
loadWorkflowFromNormalizedTables,
undeployWorkflow,
} from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { createSchedulesForDeploy, validateWorkflowSchedules } from '@/lib/workflows/schedules'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -37,11 +28,10 @@ interface RouteParams {
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const { id: workflowId } = await context.params
const requestId = generateRequestId()
try {
const [workflowRecord] = await db
.select()
.select({ id: workflow.id, name: workflow.name })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
@@ -60,18 +50,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployResult = await deployWorkflow({
workflowId,
deployedBy: ADMIN_ACTOR_ID,
@@ -87,32 +65,6 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return internalErrorResponse('Failed to resolve deployment version')
}
const workflowData = workflowRecord as Record<string, unknown>
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId,
workflow: workflowData,
userId: workflowRecord.userId,
blocks: normalizedData.blocks,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
previousVersionId,
})
if (!triggerSaveResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
})
await undeployWorkflow({ workflowId })
return internalErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration'
)
}
const scheduleResult = await createSchedulesForDeploy(
workflowId,
normalizedData.blocks,
@@ -120,58 +72,15 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
deployResult.deploymentVersionId
)
if (!scheduleResult.success) {
logger.error(
`[${requestId}] Admin API: Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`
)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: deployResult.deploymentVersionId,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
await undeployWorkflow({ workflowId })
return internalErrorResponse(scheduleResult.error || 'Failed to create schedule')
logger.warn(`Schedule creation failed for workflow ${workflowId}: ${scheduleResult.error}`)
}
if (previousVersionId && previousVersionId !== deployResult.deploymentVersionId) {
try {
logger.info(`[${requestId}] Admin API: Cleaning up previous version ${previousVersionId}`)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(
`[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
logger.info(
`[${requestId}] Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`
)
// Sync MCP tools with the latest parameter schema
await syncMcpToolsForWorkflow({ workflowId, requestId, context: 'deploy' })
logger.info(`Admin API: Deployed workflow ${workflowId} as v${deployResult.version}`)
const response: AdminDeployResult = {
isDeployed: true,
version: deployResult.version!,
deployedAt: deployResult.deployedAt!.toISOString(),
warnings: triggerSaveResult.warnings,
}
return singleResponse(response)
@@ -196,6 +105,7 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
return notFoundResponse('Workflow')
}
// Clean up external webhook subscriptions before undeploying
await cleanupWebhooksForWorkflow(
workflowId,
workflowRecord as Record<string, unknown>,
@@ -207,8 +117,6 @@ export const DELETE = withAdminAuthParams<RouteParams>(async (request, context)
return internalErrorResponse(result.error || 'Failed to undeploy workflow')
}
await removeMcpToolsForWorkflow(workflowId, requestId)
logger.info(`Admin API: Undeployed workflow ${workflowId}`)
const response: AdminUndeployResult = {

View File

@@ -1,15 +1,7 @@
import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { db, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { eq } from 'drizzle-orm'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
createSchedulesForDeploy,
validateWorkflowSchedules,
} from '@/lib/workflows/schedules'
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
import {
badRequestResponse,
@@ -17,7 +9,6 @@ import {
notFoundResponse,
singleResponse,
} from '@/app/api/v1/admin/responses'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AdminWorkflowActivateVersionAPI')
@@ -27,12 +18,11 @@ interface RouteParams {
}
export const POST = withAdminAuthParams<RouteParams>(async (request, context) => {
const requestId = generateRequestId()
const { id: workflowId, versionId } = await context.params
try {
const [workflowRecord] = await db
.select()
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
@@ -46,161 +36,23 @@ export const POST = withAdminAuthParams<RouteParams>(async (request, context) =>
return badRequestResponse('Invalid version number')
}
const [versionRow] = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.version, versionNum)
)
)
.limit(1)
if (!versionRow?.state) {
return notFoundResponse('Deployment version')
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployedState = versionRow.state as { blocks?: Record<string, BlockState> }
const blocks = deployedState.blocks
if (!blocks || typeof blocks !== 'object') {
return internalErrorResponse('Invalid deployed state structure')
}
const workflowData = workflowRecord as Record<string, unknown>
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return badRequestResponse(`Invalid schedule configuration: ${scheduleValidation.error}`)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId,
workflow: workflowData,
userId: workflowRecord.userId,
blocks,
requestId,
deploymentVersionId: versionRow.id,
previousVersionId,
forceRecreateSubscriptions: true,
})
if (!triggerSaveResult.success) {
logger.error(
`[${requestId}] Admin API: Failed to sync triggers for workflow ${workflowId}`,
triggerSaveResult.error
)
return internalErrorResponse(
triggerSaveResult.error?.message || 'Failed to sync trigger configuration'
)
}
const scheduleResult = await createSchedulesForDeploy(workflowId, blocks, db, versionRow.id)
if (!scheduleResult.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
return internalErrorResponse(scheduleResult.error || 'Failed to sync schedules')
}
const result = await activateWorkflowVersion({ workflowId, version: versionNum })
if (!result.success) {
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData,
userId: workflowRecord.userId,
previousVersionId,
requestId,
})
}
if (result.error === 'Deployment version not found') {
return notFoundResponse('Deployment version')
}
return internalErrorResponse(result.error || 'Failed to activate version')
}
if (previousVersionId && previousVersionId !== versionRow.id) {
try {
logger.info(
`[${requestId}] Admin API: Cleaning up previous version ${previousVersionId} webhooks/schedules`
)
await cleanupDeploymentVersion({
workflowId,
workflow: workflowData,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
logger.info(`[${requestId}] Admin API: Previous version cleanup completed`)
} catch (cleanupError) {
logger.error(
`[${requestId}] Admin API: Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
}
}
await syncMcpToolsForWorkflow({
workflowId,
requestId,
state: versionRow.state,
context: 'activate',
})
logger.info(
`[${requestId}] Admin API: Activated version ${versionNum} for workflow ${workflowId}`
)
logger.info(`Admin API: Activated version ${versionNum} for workflow ${workflowId}`)
return singleResponse({
success: true,
version: versionNum,
deployedAt: result.deployedAt!.toISOString(),
warnings: triggerSaveResult.warnings,
})
} catch (error) {
logger.error(
`[${requestId}] Admin API: Failed to activate version for workflow ${workflowId}`,
{
error,
}
)
logger.error(`Admin API: Failed to activate version for workflow ${workflowId}`, { error })
return internalErrorResponse('Failed to activate deployment version')
}
})

View File

@@ -11,6 +11,7 @@ import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import { env } from '@/lib/core/config/env'
import { getCostMultiplier, isBillingEnabled } from '@/lib/core/config/feature-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { enrichTableSchema } from '@/lib/table/llm/wand'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import { getModelPricing } from '@/providers/utils'
@@ -60,6 +61,7 @@ interface RequestBody {
history?: ChatMessage[]
workflowId?: string
generationType?: string
wandContext?: Record<string, unknown>
}
function safeStringify(value: unknown): string {
@@ -70,6 +72,38 @@ function safeStringify(value: unknown): string {
}
}
/**
* Wand enricher function type.
* Enrichers add context to the system prompt based on generationType.
*/
type WandEnricher = (
workspaceId: string | null,
context: Record<string, unknown>
) => Promise<string | null>
/**
* Registry of wand enrichers by generationType.
* Each enricher returns additional context to append to the system prompt.
*/
const wandEnrichers: Partial<Record<string, WandEnricher>> = {
timestamp: async () => {
const now = new Date()
return `Current date and time context for reference:
- Current UTC timestamp: ${now.toISOString()}
- Current Unix timestamp (seconds): ${Math.floor(now.getTime() / 1000)}
- Current Unix timestamp (milliseconds): ${now.getTime()}
- Current date (UTC): ${now.toISOString().split('T')[0]}
- Current year: ${now.getUTCFullYear()}
- Current month: ${now.getUTCMonth() + 1}
- Current day of month: ${now.getUTCDate()}
- Current day of week: ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getUTCDay()]}
Use this context to calculate relative dates like "yesterday", "last week", "beginning of this month", etc.`
},
'table-schema': enrichTableSchema,
}
async function updateUserStatsForWand(
userId: string,
usage: {
@@ -159,7 +193,15 @@ export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as RequestBody
const { prompt, systemPrompt, stream = false, history = [], workflowId, generationType } = body
const {
prompt,
systemPrompt,
stream = false,
history = [],
workflowId,
generationType,
wandContext = {},
} = body
if (!prompt) {
logger.warn(`[${requestId}] Invalid request: Missing prompt.`)
@@ -227,20 +269,15 @@ export async function POST(req: NextRequest) {
systemPrompt ||
'You are a helpful AI assistant. Generate content exactly as requested by the user.'
if (generationType === 'timestamp') {
const now = new Date()
const currentTimeContext = `\n\nCurrent date and time context for reference:
- Current UTC timestamp: ${now.toISOString()}
- Current Unix timestamp (seconds): ${Math.floor(now.getTime() / 1000)}
- Current Unix timestamp (milliseconds): ${now.getTime()}
- Current date (UTC): ${now.toISOString().split('T')[0]}
- Current year: ${now.getUTCFullYear()}
- Current month: ${now.getUTCMonth() + 1}
- Current day of month: ${now.getUTCDate()}
- Current day of week: ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][now.getUTCDay()]}
Use this context to calculate relative dates like "yesterday", "last week", "beginning of this month", etc.`
finalSystemPrompt += currentTimeContext
// Apply enricher if one exists for this generationType
if (generationType) {
const enricher = wandEnrichers[generationType]
if (enricher) {
const enrichment = await enricher(workspaceId, wandContext)
if (enrichment) {
finalSystemPrompt += `\n\n${enrichment}`
}
}
}
if (generationType === 'json-object') {

View File

@@ -7,7 +7,11 @@ import { getSession } from '@/lib/auth'
import { validateInteger } from '@/lib/core/security/input-validation'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
import {
cleanupExternalWebhook,
createExternalWebhookSubscription,
shouldRecreateExternalWebhookSubscription,
} from '@/lib/webhooks/provider-subscriptions'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WebhookAPI')
@@ -83,6 +87,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
}
// Update a webhook
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
@@ -97,7 +102,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
const body = await request.json()
const { isActive, failedCount } = body
const { path, provider, providerConfig, isActive, failedCount } = body
if (failedCount !== undefined) {
const validation = validateInteger(failedCount, 'failedCount', { min: 0 })
@@ -107,6 +112,28 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
}
let resolvedProviderConfig = providerConfig
if (providerConfig) {
const { resolveEnvVarsInObject } = await import('@/lib/webhooks/env-resolver')
const webhookDataForResolve = await db
.select({
workspaceId: workflow.workspaceId,
})
.from(webhook)
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
.where(eq(webhook.id, id))
.limit(1)
if (webhookDataForResolve.length > 0) {
resolvedProviderConfig = await resolveEnvVarsInObject(
providerConfig,
session.user.id,
webhookDataForResolve[0].workspaceId || undefined
)
}
}
// Find the webhook and check permissions
const webhooks = await db
.select({
webhook: webhook,
@@ -127,12 +154,16 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
const webhookData = webhooks[0]
// Check if user has permission to modify this webhook
let canModify = false
// Case 1: User owns the workflow
if (webhookData.workflow.userId === session.user.id) {
canModify = true
}
// Case 2: Workflow belongs to a workspace and user has write or admin permission
if (!canModify && webhookData.workflow.workspaceId) {
const userPermission = await getUserEntityPermissions(
session.user.id,
@@ -151,14 +182,76 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const existingProviderConfig =
(webhookData.webhook.providerConfig as Record<string, unknown>) || {}
let nextProviderConfig =
providerConfig !== undefined &&
resolvedProviderConfig &&
typeof resolvedProviderConfig === 'object'
? (resolvedProviderConfig as Record<string, unknown>)
: existingProviderConfig
const nextProvider = (provider ?? webhookData.webhook.provider) as string
if (
providerConfig !== undefined &&
shouldRecreateExternalWebhookSubscription({
previousProvider: webhookData.webhook.provider as string,
nextProvider,
previousConfig: existingProviderConfig,
nextConfig: nextProviderConfig,
})
) {
await cleanupExternalWebhook(
{ ...webhookData.webhook, providerConfig: existingProviderConfig },
webhookData.workflow,
requestId
)
const result = await createExternalWebhookSubscription(
request,
{
...webhookData.webhook,
provider: nextProvider,
providerConfig: nextProviderConfig,
},
webhookData.workflow,
session.user.id,
requestId
)
nextProviderConfig = result.updatedProviderConfig as Record<string, unknown>
}
logger.debug(`[${requestId}] Updating webhook properties`, {
hasPathUpdate: path !== undefined,
hasProviderUpdate: provider !== undefined,
hasConfigUpdate: providerConfig !== undefined,
hasActiveUpdate: isActive !== undefined,
hasFailedCountUpdate: failedCount !== undefined,
})
// Merge providerConfig to preserve credential-related fields
let finalProviderConfig = webhooks[0].webhook.providerConfig
if (providerConfig !== undefined) {
const existingConfig = existingProviderConfig
finalProviderConfig = {
...nextProviderConfig,
credentialId: existingConfig.credentialId,
credentialSetId: existingConfig.credentialSetId,
userId: existingConfig.userId,
historyId: existingConfig.historyId,
lastCheckedTimestamp: existingConfig.lastCheckedTimestamp,
setupCompleted: existingConfig.setupCompleted,
externalId: nextProviderConfig.externalId ?? existingConfig.externalId,
}
}
const updatedWebhook = await db
.update(webhook)
.set({
path: path !== undefined ? path : webhooks[0].webhook.path,
provider: provider !== undefined ? provider : webhooks[0].webhook.provider,
providerConfig: finalProviderConfig,
isActive: isActive !== undefined ? isActive : webhooks[0].webhook.isActive,
failedCount: failedCount !== undefined ? failedCount : webhooks[0].webhook.failedCount,
updatedAt: new Date(),
@@ -241,8 +334,11 @@ export async function DELETE(
}
const foundWebhook = webhookData.webhook
const credentialSetId = foundWebhook.credentialSetId as string | undefined
const blockId = foundWebhook.blockId as string | undefined
const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions')
const providerConfig = foundWebhook.providerConfig as Record<string, unknown> | null
const credentialSetId = providerConfig?.credentialSetId as string | undefined
const blockId = providerConfig?.blockId as string | undefined
if (credentialSetId && blockId) {
const allCredentialSetWebhooks = await db
@@ -250,9 +346,10 @@ export async function DELETE(
.from(webhook)
.where(and(eq(webhook.workflowId, webhookData.workflow.id), eq(webhook.blockId, blockId)))
const webhooksToDelete = allCredentialSetWebhooks.filter(
(w) => w.credentialSetId === credentialSetId
)
const webhooksToDelete = allCredentialSetWebhooks.filter((w) => {
const config = w.providerConfig as Record<string, unknown> | null
return config?.credentialSetId === credentialSetId
})
for (const w of webhooksToDelete) {
await cleanupExternalWebhook(w, webhookData.workflow, requestId)

View File

@@ -7,21 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
import { getProviderIdFromServiceId } from '@/lib/oauth'
import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver'
import {
cleanupExternalWebhook,
createExternalWebhookSubscription,
} from '@/lib/webhooks/provider-subscriptions'
import { mergeNonUserFields } from '@/lib/webhooks/utils'
import {
configureGmailPolling,
configureOutlookPolling,
configureRssPolling,
syncWebhooksForCredentialSet,
} from '@/lib/webhooks/utils.server'
import { createExternalWebhookSubscription } from '@/lib/webhooks/provider-subscriptions'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants'
const logger = createLogger('WebhooksAPI')
@@ -311,10 +298,14 @@ export async function POST(request: NextRequest) {
}
}
let savedWebhook: any = null
const originalProviderConfig = providerConfig || {}
let savedWebhook: any = null // Variable to hold the result of save/update
// Use the original provider config - Gmail/Outlook configuration functions will inject userId automatically
const finalProviderConfig = providerConfig || {}
const { resolveEnvVarsInObject } = await import('@/lib/webhooks/env-resolver')
let resolvedProviderConfig = await resolveEnvVarsInObject(
originalProviderConfig,
finalProviderConfig,
userId,
workflowRecord.workspaceId || undefined
)
@@ -328,6 +319,8 @@ export async function POST(request: NextRequest) {
const directCredentialSetId = resolvedProviderConfig?.credentialSetId as string | undefined
if (directCredentialSetId || rawCredentialId) {
const { isCredentialSetValue, extractCredentialSetId } = await import('@/executor/constants')
const credentialSetId =
directCredentialSetId ||
(rawCredentialId && isCredentialSetValue(rawCredentialId)
@@ -339,6 +332,11 @@ export async function POST(request: NextRequest) {
`[${requestId}] Credential set detected for ${provider} trigger. Syncing webhooks for set ${credentialSetId}`
)
const { getProviderIdFromServiceId } = await import('@/lib/oauth')
const { syncWebhooksForCredentialSet, configureGmailPolling, configureOutlookPolling } =
await import('@/lib/webhooks/utils.server')
// Map provider to OAuth provider ID
const oauthProviderId = getProviderIdFromServiceId(provider)
const {
@@ -471,9 +469,6 @@ export async function POST(request: NextRequest) {
providerConfig: providerConfigOverride,
})
const userProvided = originalProviderConfig as Record<string, unknown>
const configToSave: Record<string, unknown> = { ...userProvided }
try {
const result = await createExternalWebhookSubscription(
request,
@@ -482,9 +477,7 @@ export async function POST(request: NextRequest) {
userId,
requestId
)
const updatedConfig = result.updatedProviderConfig as Record<string, unknown>
mergeNonUserFields(configToSave, updatedConfig, userProvided)
resolvedProviderConfig = updatedConfig
resolvedProviderConfig = result.updatedProviderConfig as Record<string, unknown>
externalSubscriptionCreated = result.externalSubscriptionCreated
} catch (err) {
logger.error(`[${requestId}] Error creating external webhook subscription`, err)
@@ -497,22 +490,25 @@ export async function POST(request: NextRequest) {
)
}
// Now save to database (only if subscription succeeded or provider doesn't need external subscription)
try {
if (targetWebhookId) {
logger.info(`[${requestId}] Updating existing webhook for path: ${finalPath}`, {
webhookId: targetWebhookId,
provider,
hasCredentialId: !!(configToSave as any)?.credentialId,
credentialId: (configToSave as any)?.credentialId,
hasCredentialId: !!(resolvedProviderConfig as any)?.credentialId,
credentialId: (resolvedProviderConfig as any)?.credentialId,
})
const updatedResult = await db
.update(webhook)
.set({
blockId,
provider,
providerConfig: configToSave,
providerConfig: resolvedProviderConfig,
credentialSetId:
((configToSave as Record<string, unknown>)?.credentialSetId as string | null) || null,
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
| string
| null) || null,
isActive: true,
updatedAt: new Date(),
})
@@ -535,9 +531,11 @@ export async function POST(request: NextRequest) {
blockId,
path: finalPath,
provider,
providerConfig: configToSave,
providerConfig: resolvedProviderConfig,
credentialSetId:
((configToSave as Record<string, unknown>)?.credentialSetId as string | null) || null,
((resolvedProviderConfig as Record<string, unknown>)?.credentialSetId as
| string
| null) || null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
@@ -549,8 +547,9 @@ export async function POST(request: NextRequest) {
if (externalSubscriptionCreated) {
logger.error(`[${requestId}] DB save failed, cleaning up external subscription`, dbError)
try {
const { cleanupExternalWebhook } = await import('@/lib/webhooks/provider-subscriptions')
await cleanupExternalWebhook(
createTempWebhookData(configToSave),
createTempWebhookData(resolvedProviderConfig),
workflowRecord,
requestId
)
@@ -568,6 +567,7 @@ export async function POST(request: NextRequest) {
if (savedWebhook && provider === 'gmail') {
logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`)
try {
const { configureGmailPolling } = await import('@/lib/webhooks/utils.server')
const success = await configureGmailPolling(savedWebhook, requestId)
if (!success) {
@@ -606,6 +606,7 @@ export async function POST(request: NextRequest) {
`[${requestId}] Outlook provider detected. Setting up Outlook webhook configuration.`
)
try {
const { configureOutlookPolling } = await import('@/lib/webhooks/utils.server')
const success = await configureOutlookPolling(savedWebhook, requestId)
if (!success) {
@@ -642,6 +643,7 @@ export async function POST(request: NextRequest) {
if (savedWebhook && provider === 'rss') {
logger.info(`[${requestId}] RSS provider detected. Setting up RSS webhook configuration.`)
try {
const { configureRssPolling } = await import('@/lib/webhooks/utils.server')
const success = await configureRssPolling(savedWebhook, requestId)
if (!success) {

View File

@@ -4,11 +4,7 @@ import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
cleanupWebhooksForWorkflow,
restorePreviousVersionWebhooks,
saveTriggerWebhooksForDeploy,
} from '@/lib/webhooks/deploy'
import { cleanupWebhooksForWorkflow, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import {
deployWorkflow,
loadWorkflowFromNormalizedTables,
@@ -139,18 +135,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const [currentActiveVersion] = await db
.select({ id: workflowDeploymentVersion.id })
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, id),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
const previousVersionId = currentActiveVersion?.id
const deployResult = await deployWorkflow({
workflowId: id,
deployedBy: actorUserId,
@@ -177,7 +161,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
blocks: normalizedData.blocks,
requestId,
deploymentVersionId,
previousVersionId,
})
if (!triggerSaveResult.success) {
@@ -211,15 +194,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
requestId,
deploymentVersionId,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
}
await undeployWorkflow({ workflowId: id })
return createErrorResponse(scheduleResult.error || 'Failed to create schedule', 500)
}
@@ -234,25 +208,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
)
}
if (previousVersionId && previousVersionId !== deploymentVersionId) {
try {
logger.info(`[${requestId}] Cleaning up previous version ${previousVersionId} DB records`)
await cleanupDeploymentVersion({
workflowId: id,
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
} catch (cleanupError) {
logger.error(
`[${requestId}] Failed to clean up previous version ${previousVersionId}`,
cleanupError
)
// Non-fatal - continue with success response
}
}
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
// Sync MCP tools with the latest parameter schema
@@ -273,7 +228,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
nextRunAt: scheduleInfo.nextRunAt,
}
: undefined,
warnings: triggerSaveResult.warnings,
})
} catch (error: any) {
logger.error(`[${requestId}] Error deploying workflow: ${id}`, {

View File

@@ -4,7 +4,7 @@ import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
import { activateWorkflowVersion } from '@/lib/workflows/persistence/utils'
import {
cleanupDeploymentVersion,
@@ -85,11 +85,6 @@ export async function POST(
return createErrorResponse('Invalid deployed state structure', 500)
}
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const triggerSaveResult = await saveTriggerWebhooksForDeploy({
request,
workflowId: id,
@@ -98,8 +93,6 @@ export async function POST(
blocks,
requestId,
deploymentVersionId: versionRow.id,
previousVersionId,
forceRecreateSubscriptions: true,
})
if (!triggerSaveResult.success) {
@@ -109,6 +102,11 @@ export async function POST(
)
}
const scheduleValidation = validateWorkflowSchedules(blocks)
if (!scheduleValidation.isValid) {
return createErrorResponse(`Invalid schedule configuration: ${scheduleValidation.error}`, 400)
}
const scheduleResult = await createSchedulesForDeploy(id, blocks, db, versionRow.id)
if (!scheduleResult.success) {
@@ -118,15 +116,6 @@ export async function POST(
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
}
return createErrorResponse(scheduleResult.error || 'Failed to sync schedules', 500)
}
@@ -138,15 +127,6 @@ export async function POST(
requestId,
deploymentVersionId: versionRow.id,
})
if (previousVersionId) {
await restorePreviousVersionWebhooks({
request,
workflow: workflowData as Record<string, unknown>,
userId: actorUserId,
previousVersionId,
requestId,
})
}
return createErrorResponse(result.error || 'Failed to activate deployment', 400)
}
@@ -160,7 +140,6 @@ export async function POST(
workflow: workflowData as Record<string, unknown>,
requestId,
deploymentVersionId: previousVersionId,
skipExternalCleanup: true,
})
logger.info(`[${requestId}] Previous version cleanup completed`)
} catch (cleanupError) {
@@ -178,11 +157,7 @@ export async function POST(
context: 'activate',
})
return createSuccessResponse({
success: true,
deployedAt: result.deployedAt,
warnings: triggerSaveResult.warnings,
})
return createSuccessResponse({ success: true, deployedAt: result.deployedAt })
} catch (error: any) {
logger.error(`[${requestId}] Error activating deployment for workflow: ${id}`, error)
return createErrorResponse(error.message || 'Failed to activate deployment', 500)

View File

@@ -116,6 +116,7 @@ type AsyncExecutionParams = {
userId: string
input: any
triggerType: CoreTriggerType
preflighted?: boolean
}
/**
@@ -138,6 +139,7 @@ async function handleAsyncExecution(params: AsyncExecutionParams): Promise<NextR
userId,
input,
triggerType,
preflighted: params.preflighted,
}
try {
@@ -274,6 +276,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId
)
const shouldPreflightEnvVars = isAsyncMode && isTriggerDevEnabled
const preprocessResult = await preprocessExecution({
workflowId,
userId,
@@ -282,7 +285,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
requestId,
checkDeployment: !shouldUseDraftState,
loggingSession,
preflightEnvVars: shouldPreflightEnvVars,
useDraftState: shouldUseDraftState,
envUserId: isClientSession ? userId : undefined,
})
if (!preprocessResult.success) {
@@ -314,6 +319,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
userId: actorUserId,
input,
triggerType: loggingTriggerType,
preflighted: shouldPreflightEnvVars,
})
}

View File

@@ -1,27 +0,0 @@
'use client'
import Link from 'next/link'
import { useBrandedButtonClass } from '@/hooks/use-branded-button-class'
interface BrandedLinkProps {
href: string
children: React.ReactNode
className?: string
target?: string
rel?: string
}
export function BrandedLink({ href, children, className = '', target, rel }: BrandedLinkProps) {
const buttonClass = useBrandedButtonClass()
return (
<Link
href={href}
target={target}
rel={rel}
className={`${buttonClass} group inline-flex items-center justify-center gap-2 rounded-[10px] py-[6px] pr-[10px] pl-[12px] text-[15px] text-white transition-all ${className}`}
>
{children}
</Link>
)
}

View File

@@ -2,7 +2,6 @@ import { BookOpen, Github, Rss } from 'lucide-react'
import Link from 'next/link'
import { inter } from '@/app/_styles/fonts/inter/inter'
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
import { BrandedLink } from '@/app/changelog/components/branded-link'
import ChangelogList from '@/app/changelog/components/timeline-list'
export interface ChangelogEntry {
@@ -67,24 +66,25 @@ export default async function ChangelogContent() {
<hr className='mt-6 border-border' />
<div className='mt-6 flex flex-wrap items-center gap-3 text-sm'>
<BrandedLink
<Link
href='https://github.com/simstudioai/sim/releases'
target='_blank'
rel='noopener noreferrer'
className='group inline-flex items-center justify-center gap-2 rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] py-[6px] pr-[10px] pl-[12px] text-[14px] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all sm:text-[16px]'
>
<Github className='h-4 w-4' />
View on GitHub
</BrandedLink>
</Link>
<Link
href='https://docs.sim.ai'
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
>
<BookOpen className='h-4 w-4' />
Documentation
</Link>
<Link
href='/changelog.xml'
className='inline-flex items-center gap-2 rounded-[10px] border border-border py-[6px] pr-[10px] pl-[12px] text-[15px] transition-all hover:bg-muted'
className='inline-flex items-center gap-2 rounded-md border border-border px-3 py-1.5 hover:bg-muted'
>
<Rss className='h-4 w-4' />
RSS Feed

View File

@@ -117,7 +117,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const [error, setError] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
const [starCount, setStarCount] = useState('25.8k')
const [starCount, setStarCount] = useState('25.1k')
const [conversationId, setConversationId] = useState('')
const [showScrollButton, setShowScrollButton] = useState(false)

View File

@@ -207,6 +207,7 @@ function TemplateCardInner({
isPannable={false}
defaultZoom={0.8}
fitPadding={0.2}
lightweight
/>
) : (
<div className='h-full w-full bg-[var(--surface-4)]' />

View File

@@ -555,7 +555,7 @@ export function DocumentTagsModal({
Cancel
</Button>
<Button
variant='tertiary'
variant={canSaveTag ? 'tertiary' : 'default'}
onClick={saveDocumentTag}
className='flex-1'
disabled={!canSaveTag}

View File

@@ -61,7 +61,6 @@ export function EditChunkModal({
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
const [tokenizerOn, setTokenizerOn] = useState(false)
const [hoveredTokenIndex, setHoveredTokenIndex] = useState<number | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const error = mutationError?.message ?? null
@@ -255,8 +254,6 @@ export function EditChunkModal({
style={{
backgroundColor: getTokenBgColor(index),
}}
onMouseEnter={() => setHoveredTokenIndex(index)}
onMouseLeave={() => setHoveredTokenIndex(null)}
>
{token}
</span>
@@ -284,11 +281,6 @@ export function EditChunkModal({
<div className='flex items-center gap-[8px]'>
<span className='text-[12px] text-[var(--text-secondary)]'>Tokenizer</span>
<Switch checked={tokenizerOn} onCheckedChange={setTokenizerOn} />
{tokenizerOn && hoveredTokenIndex !== null && (
<span className='text-[12px] text-[var(--text-tertiary)]'>
Token #{hoveredTokenIndex + 1}
</span>
)}
</div>
<span className='text-[12px] text-[var(--text-secondary)]'>
{tokenCount.toLocaleString()}
@@ -308,7 +300,7 @@ export function EditChunkModal({
</Button>
{userPermissions.canEdit && (
<Button
variant='tertiary'
variant={hasUnsavedChanges ? 'tertiary' : 'default'}
onClick={handleSaveContent}
type='button'
disabled={!isFormValid || isSaving || !hasUnsavedChanges || isNavigating}

View File

@@ -36,7 +36,6 @@ import {
import { Input } from '@/components/ui/input'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { Skeleton } from '@/components/ui/skeleton'
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
import type { ChunkData } from '@/lib/knowledge/types'
import {
ChunkContextMenu,
@@ -59,6 +58,55 @@ import {
const logger = createLogger('Document')
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
/**
* Formats a date string to absolute format for tooltip display
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
interface DocumentProps {
knowledgeBaseId: string
documentId: string
@@ -256,6 +304,7 @@ export function Document({
const [searchQuery, setSearchQuery] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
const {
chunks: initialChunks,
@@ -295,6 +344,7 @@ export function Document({
const handler = setTimeout(() => {
startTransition(() => {
setDebouncedSearchQuery(searchQuery)
setIsSearching(searchQuery.trim().length > 0)
})
}, 200)
@@ -303,7 +353,6 @@ export function Document({
}
}, [searchQuery])
const isSearching = debouncedSearchQuery.trim().length > 0
const showingSearch = isSearching && searchQuery.trim().length > 0 && searchResults.length > 0
const SEARCH_PAGE_SIZE = 50
const maxSearchPages = Math.ceil(searchResults.length / SEARCH_PAGE_SIZE)

View File

@@ -27,10 +27,6 @@ import {
ModalContent,
ModalFooter,
ModalHeader,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Table,
TableBody,
TableCell,
@@ -44,11 +40,8 @@ import { Input } from '@/components/ui/input'
import { SearchHighlight } from '@/components/ui/search-highlight'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/core/utils/cn'
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import type { DocumentData } from '@/lib/knowledge/types'
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
import {
ActionBar,
AddDocumentsModal,
@@ -196,8 +189,8 @@ function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps)
</div>
</div>
<div>
<Skeleton className='mt-[4px] h-[21px] w-[300px] rounded-[4px]' />
<div className='mt-[4px]'>
<Skeleton className='h-[21px] w-[300px] rounded-[4px]' />
</div>
<div className='mt-[16px] flex items-center gap-[8px]'>
@@ -215,12 +208,9 @@ function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps)
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
<div className='flex items-center gap-[8px]'>
<Skeleton className='h-[32px] w-[52px] rounded-[6px]' />
<Button disabled variant='tertiary' className='h-[32px] rounded-[6px]'>
Add Documents
</Button>
</div>
<Button disabled variant='tertiary' className='h-[32px] rounded-[6px]'>
Add Documents
</Button>
</div>
<div className='mt-[12px] flex flex-1 flex-col overflow-hidden'>
@@ -232,11 +222,73 @@ function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps)
)
}
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
/**
* Formats a date string to absolute format for tooltip display
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
interface KnowledgeBaseProps {
id: string
knowledgeBaseName?: string
}
function getFileIcon(mimeType: string, filename: string) {
const IconComponent = getDocumentIcon(mimeType, filename)
return <IconComponent className='h-6 w-5 flex-shrink-0' />
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
}
const AnimatedLoader = ({ className }: { className?: string }) => (
<Loader2 className={cn(className, 'animate-spin')} />
)
@@ -284,24 +336,53 @@ const getStatusBadge = (doc: DocumentData) => {
}
}
const TAG_SLOTS = [
'tag1',
'tag2',
'tag3',
'tag4',
'tag5',
'tag6',
'tag7',
'number1',
'number2',
'number3',
'number4',
'number5',
'date1',
'date2',
'boolean1',
'boolean2',
'boolean3',
] as const
type TagSlot = (typeof TAG_SLOTS)[number]
interface TagValue {
slot: AllTagSlot
slot: TagSlot
displayName: string
value: string
}
const TAG_FIELD_TYPES: Record<string, string> = {
tag: 'text',
number: 'number',
date: 'date',
boolean: 'boolean',
}
/**
* Computes tag values for a document
*/
function getDocumentTags(doc: DocumentData, definitions: TagDefinition[]): TagValue[] {
const result: TagValue[] = []
for (const slot of ALL_TAG_SLOTS) {
for (const slot of TAG_SLOTS) {
const raw = doc[slot]
if (raw == null) continue
const def = definitions.find((d) => d.tagSlot === slot)
const fieldType = def?.fieldType || getFieldTypeForSlot(slot) || 'text'
const fieldType = def?.fieldType || TAG_FIELD_TYPES[slot.replace(/\d+$/, '')] || 'text'
let value: string
if (fieldType === 'date') {
@@ -343,8 +424,6 @@ export function KnowledgeBase({
const [searchQuery, setSearchQuery] = useState('')
const [showTagsModal, setShowTagsModal] = useState(false)
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
const [isFilterPopoverOpen, setIsFilterPopoverOpen] = useState(false)
/**
* Memoize the search query setter to prevent unnecessary re-renders
@@ -355,7 +434,6 @@ export function KnowledgeBase({
}, [])
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set())
const [isSelectAllMode, setIsSelectAllMode] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false)
const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false)
@@ -382,6 +460,7 @@ export function KnowledgeBase({
error: knowledgeBaseError,
refresh: refreshKnowledgeBase,
} = useKnowledgeBase(id)
const [hasProcessingDocuments, setHasProcessingDocuments] = useState(false)
const {
documents,
@@ -390,7 +469,6 @@ export function KnowledgeBase({
isFetching: isFetchingDocuments,
isPlaceholderData: isPlaceholderDocuments,
error: documentsError,
hasProcessingDocuments,
updateDocument,
refreshDocuments,
} = useKnowledgeBaseDocuments(id, {
@@ -399,14 +477,7 @@ export function KnowledgeBase({
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
sortBy,
sortOrder,
refetchInterval: (data) => {
if (isDeleting) return false
const hasPending = data?.documents?.some(
(doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing'
)
return hasPending ? 3000 : false
},
enabledFilter,
refetchInterval: hasProcessingDocuments && !isDeleting ? 3000 : false,
})
const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id)
@@ -472,52 +543,52 @@ export function KnowledgeBase({
</TableHead>
)
useEffect(() => {
const processing = documents.some(
(doc) => doc.processingStatus === 'pending' || doc.processingStatus === 'processing'
)
setHasProcessingDocuments(processing)
if (processing) {
checkForDeadProcesses()
}
}, [documents])
/**
* Checks for documents with stale processing states and marks them as failed
*/
const checkForDeadProcesses = useCallback(
(docsToCheck: DocumentData[]) => {
const now = new Date()
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
const checkForDeadProcesses = () => {
const now = new Date()
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
const staleDocuments = docsToCheck.filter((doc) => {
if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) {
return false
}
const staleDocuments = documents.filter((doc) => {
if (doc.processingStatus !== 'processing' || !doc.processingStartedAt) {
return false
}
const processingDuration = now.getTime() - new Date(doc.processingStartedAt).getTime()
return processingDuration > DEAD_PROCESS_THRESHOLD_MS
})
const processingDuration = now.getTime() - new Date(doc.processingStartedAt).getTime()
return processingDuration > DEAD_PROCESS_THRESHOLD_MS
})
if (staleDocuments.length === 0) return
if (staleDocuments.length === 0) return
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
staleDocuments.forEach((doc) => {
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: doc.id,
updates: { markFailedDueToTimeout: true },
staleDocuments.forEach((doc) => {
updateDocumentMutation(
{
knowledgeBaseId: id,
documentId: doc.id,
updates: { markFailedDueToTimeout: true },
},
{
onSuccess: () => {
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
},
{
onSuccess: () => {
logger.info(
`Successfully marked dead process as failed for document: ${doc.filename}`
)
},
}
)
})
},
[id, updateDocumentMutation]
)
useEffect(() => {
if (hasProcessingDocuments) {
checkForDeadProcesses(documents)
}
}, [hasProcessingDocuments, documents, checkForDeadProcesses])
}
)
})
}
const handleToggleEnabled = (docId: string) => {
const document = documents.find((doc) => doc.id === docId)
@@ -677,7 +748,6 @@ export function KnowledgeBase({
setSelectedDocuments(new Set(documents.map((doc) => doc.id)))
} else {
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}
}
@@ -723,26 +793,6 @@ export function KnowledgeBase({
* Handles bulk enabling of selected documents
*/
const handleBulkEnable = () => {
if (isSelectAllMode) {
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'enable',
selectAll: true,
enabledFilter,
},
{
onSuccess: (result) => {
logger.info(`Successfully enabled ${result.successCount} documents`)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
refreshDocuments()
},
}
)
return
}
const documentsToEnable = documents.filter(
(doc) => selectedDocuments.has(doc.id) && !doc.enabled
)
@@ -771,26 +821,6 @@ export function KnowledgeBase({
* Handles bulk disabling of selected documents
*/
const handleBulkDisable = () => {
if (isSelectAllMode) {
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'disable',
selectAll: true,
enabledFilter,
},
{
onSuccess: (result) => {
logger.info(`Successfully disabled ${result.successCount} documents`)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
refreshDocuments()
},
}
)
return
}
const documentsToDisable = documents.filter(
(doc) => selectedDocuments.has(doc.id) && doc.enabled
)
@@ -815,35 +845,18 @@ export function KnowledgeBase({
)
}
/**
* Opens the bulk delete confirmation modal
*/
const handleBulkDelete = () => {
if (selectedDocuments.size === 0) return
setShowBulkDeleteModal(true)
}
/**
* Confirms and executes the bulk deletion of selected documents
*/
const confirmBulkDelete = () => {
if (isSelectAllMode) {
bulkDocumentMutation(
{
knowledgeBaseId: id,
operation: 'delete',
selectAll: true,
enabledFilter,
},
{
onSuccess: (result) => {
logger.info(`Successfully deleted ${result.successCount} documents`)
refreshDocuments()
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
},
onSettled: () => {
setShowBulkDeleteModal(false)
},
}
)
return
}
const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id))
if (documentsToDelete.length === 0) return
@@ -868,17 +881,14 @@ export function KnowledgeBase({
}
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))
const enabledCount = isSelectAllMode
? enabledFilter === 'disabled'
? 0
: pagination.total
: selectedDocumentsList.filter((doc) => doc.enabled).length
const disabledCount = isSelectAllMode
? enabledFilter === 'enabled'
? 0
: pagination.total
: selectedDocumentsList.filter((doc) => !doc.enabled).length
const enabledCount = selectedDocumentsList.filter((doc) => doc.enabled).length
const disabledCount = selectedDocumentsList.filter((doc) => !doc.enabled).length
/**
* Handle right-click on a document row
* If right-clicking on an unselected document, select only that document
* If right-clicking on a selected document with multiple selections, keep all selections
*/
const handleDocumentContextMenu = useCallback(
(e: React.MouseEvent, doc: DocumentData) => {
const isCurrentlySelected = selectedDocuments.has(doc.id)
@@ -995,13 +1005,11 @@ export function KnowledgeBase({
</div>
</div>
<div>
{knowledgeBase?.description && (
<p className='mt-[4px] line-clamp-2 max-w-[40vw] font-medium text-[14px] text-[var(--text-tertiary)]'>
{knowledgeBase.description}
</p>
)}
</div>
{knowledgeBase?.description && (
<p className='mt-[4px] line-clamp-2 max-w-[40vw] font-medium text-[14px] text-[var(--text-tertiary)]'>
{knowledgeBase.description}
</p>
)}
<div className='mt-[16px] flex items-center gap-[8px]'>
<span className='text-[14px] text-[var(--text-muted)]'>
@@ -1044,76 +1052,21 @@ export function KnowledgeBase({
))}
</div>
<div className='flex items-center gap-[8px]'>
<Popover open={isFilterPopoverOpen} onOpenChange={setIsFilterPopoverOpen}>
<PopoverTrigger asChild>
<Button variant='default' className='h-[32px] rounded-[6px]'>
{enabledFilter === 'all'
? 'All'
: enabledFilter === 'enabled'
? 'Enabled'
: 'Disabled'}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' side='bottom' sideOffset={4}>
<div className='flex flex-col gap-[2px]'>
<PopoverItem
active={enabledFilter === 'all'}
onClick={() => {
setEnabledFilter('all')
setIsFilterPopoverOpen(false)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
>
All
</PopoverItem>
<PopoverItem
active={enabledFilter === 'enabled'}
onClick={() => {
setEnabledFilter('enabled')
setIsFilterPopoverOpen(false)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
>
Enabled
</PopoverItem>
<PopoverItem
active={enabledFilter === 'disabled'}
onClick={() => {
setEnabledFilter('disabled')
setIsFilterPopoverOpen(false)
setCurrentPage(1)
setSelectedDocuments(new Set())
setIsSelectAllMode(false)
}}
>
Disabled
</PopoverItem>
</div>
</PopoverContent>
</Popover>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={handleAddDocuments}
disabled={userPermissions.canEdit !== true}
variant='tertiary'
className='h-[32px] rounded-[6px]'
>
Add Documents
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>Write permission required to add documents</Tooltip.Content>
)}
</Tooltip.Root>
</div>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
onClick={handleAddDocuments}
disabled={userPermissions.canEdit !== true}
variant='tertiary'
className='h-[32px] rounded-[6px]'
>
Add Documents
</Button>
</Tooltip.Trigger>
{userPermissions.canEdit !== true && (
<Tooltip.Content>Write permission required to add documents</Tooltip.Content>
)}
</Tooltip.Root>
</div>
{error && !isLoadingKnowledgeBase && (
@@ -1136,20 +1089,14 @@ export function KnowledgeBase({
<div className='mt-[10px] flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
{searchQuery
? 'No documents found'
: enabledFilter !== 'all'
? 'Nothing matches your filter'
: 'No documents yet'}
{searchQuery ? 'No documents found' : 'No documents yet'}
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>
{searchQuery
? 'Try a different search term'
: enabledFilter !== 'all'
? 'Try changing the filter'
: userPermissions.canEdit === true
? 'Add documents to get started'
: 'Documents will appear here once added'}
: userPermissions.canEdit === true
? 'Add documents to get started'
: 'Documents will appear here once added'}
</p>
</div>
</div>
@@ -1173,7 +1120,7 @@ export function KnowledgeBase({
{renderSortableHeader('tokenCount', 'Tokens', 'hidden w-[8%] lg:table-cell')}
{renderSortableHeader('chunkCount', 'Chunks', 'w-[8%]')}
{renderSortableHeader('uploadedAt', 'Uploaded', 'w-[11%]')}
{renderSortableHeader('enabled', 'Status', 'w-[10%]')}
{renderSortableHeader('processingStatus', 'Status', 'w-[10%]')}
<TableHead className='w-[12%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
Tags
</TableHead>
@@ -1217,10 +1164,7 @@ export function KnowledgeBase({
</TableCell>
<TableCell className='w-[180px] max-w-[180px] px-[12px] py-[8px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
{(() => {
const IconComponent = getDocumentIcon(doc.mimeType, doc.filename)
return <IconComponent className='h-6 w-5 flex-shrink-0' />
})()}
{getFileIcon(doc.mimeType, doc.filename)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
@@ -1564,14 +1508,6 @@ export function KnowledgeBase({
enabledCount={enabledCount}
disabledCount={disabledCount}
isLoading={isBulkOperating}
totalCount={pagination.total}
isAllPageSelected={isAllSelected}
isAllSelected={isSelectAllMode}
onSelectAll={() => setIsSelectAllMode(true)}
onClearSelectAll={() => {
setIsSelectAllMode(false)
setSelectedDocuments(new Set())
}}
/>
<DocumentContextMenu

View File

@@ -13,11 +13,6 @@ interface ActionBarProps {
disabledCount?: number
isLoading?: boolean
className?: string
totalCount?: number
isAllPageSelected?: boolean
isAllSelected?: boolean
onSelectAll?: () => void
onClearSelectAll?: () => void
}
export function ActionBar({
@@ -29,21 +24,14 @@ export function ActionBar({
disabledCount = 0,
isLoading = false,
className,
totalCount = 0,
isAllPageSelected = false,
isAllSelected = false,
onSelectAll,
onClearSelectAll,
}: ActionBarProps) {
const userPermissions = useUserPermissionsContext()
if (selectedCount === 0 && !isAllSelected) return null
if (selectedCount === 0) return null
const canEdit = userPermissions.canEdit
const showEnableButton = disabledCount > 0 && onEnable && canEdit
const showDisableButton = enabledCount > 0 && onDisable && canEdit
const showSelectAllOption =
isAllPageSelected && !isAllSelected && totalCount > selectedCount && onSelectAll
return (
<motion.div
@@ -55,31 +43,7 @@ export function ActionBar({
>
<div className='flex items-center gap-[8px] rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-[8px] py-[6px]'>
<span className='px-[4px] text-[13px] text-[var(--text-secondary)]'>
{isAllSelected ? totalCount : selectedCount} selected
{showSelectAllOption && (
<>
{' · '}
<button
type='button'
onClick={onSelectAll}
className='text-[var(--brand-primary)] hover:underline'
>
Select all
</button>
</>
)}
{isAllSelected && onClearSelectAll && (
<>
{' · '}
<button
type='button'
onClick={onClearSelectAll}
className='text-[var(--brand-primary)] hover:underline'
>
Clear
</button>
</>
)}
{selectedCount} selected
</span>
<div className='flex items-center gap-[5px]'>

View File

@@ -39,6 +39,9 @@ export function RenameDocumentModal({
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Check if name has changed from initial value
const hasChanges = name.trim() !== initialName.trim()
useEffect(() => {
if (open) {
setName(initialName)
@@ -124,9 +127,9 @@ export function RenameDocumentModal({
Cancel
</Button>
<Button
variant='tertiary'
variant={hasChanges ? 'tertiary' : 'default'}
type='submit'
disabled={isSubmitting || !name?.trim() || name.trim() === initialName}
disabled={isSubmitting || !name?.trim() || !hasChanges}
>
{isSubmitting ? 'Renaming...' : 'Rename'}
</Button>

View File

@@ -1,9 +1,8 @@
'use client'
import { useCallback, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
import { Badge, Button, DocumentAttachment, Tooltip } from '@/components/emcn'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -22,6 +21,55 @@ interface BaseCardProps {
onDelete?: (id: string) => Promise<void>
}
/**
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
*/
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
/**
* Formats a date string to absolute format for tooltip display
*/
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
/**
* Skeleton placeholder for a knowledge base card
*/
@@ -95,6 +143,7 @@ export function BaseCard({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const searchParams = new URLSearchParams({
kbName: title,
@@ -103,6 +152,23 @@ export function BaseCard({
const shortId = id ? `kb-${id.slice(0, 8)}` : ''
const handleMenuButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (menuButtonRef.current) {
const rect = menuButtonRef.current.getBoundingClientRect()
const syntheticEvent = {
preventDefault: () => {},
stopPropagation: () => {},
clientX: rect.right,
clientY: rect.bottom,
} as React.MouseEvent
handleContextMenu(syntheticEvent)
}
},
[handleContextMenu]
)
const handleClick = useCallback(
(e: React.MouseEvent) => {
if (isContextMenuOpen) {
@@ -175,9 +241,24 @@ export function BaseCard({
<h3 className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
{title}
</h3>
{shortId && (
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
)}
<div className='flex items-center gap-[4px]'>
{shortId && (
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
)}
<Button
ref={menuButtonRef}
variant='ghost'
size='sm'
className='h-[20px] w-[20px] flex-shrink-0 p-0 text-[var(--text-tertiary)]'
onClick={handleMenuButtonClick}
>
<svg className='h-[14px] w-[14px]' viewBox='0 0 16 16' fill='currentColor'>
<circle cx='3' cy='8' r='1.5' />
<circle cx='8' cy='8' r='1.5' />
<circle cx='13' cy='8' r='1.5' />
</svg>
</Button>
</div>
</div>
<div className='flex flex-1 flex-col gap-[8px]'>

View File

@@ -344,51 +344,53 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
<Textarea
id='description'
placeholder='Describe this knowledge base (optional)'
rows={4}
rows={3}
{...register('description')}
className={cn(errors.description && 'border-[var(--text-error)]')}
/>
</div>
<div className='grid grid-cols-2 gap-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='minChunkSize'>Min Chunk Size (characters)</Label>
<Input
id='minChunkSize'
placeholder='100'
{...register('minChunkSize', { valueAsNumber: true })}
className={cn(errors.minChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='min-chunk-size'
/>
<div className='space-y-[12px] rounded-[6px] bg-[var(--surface-5)] px-[12px] py-[14px]'>
<div className='grid grid-cols-2 gap-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='minChunkSize'>Min Chunk Size (characters)</Label>
<Input
id='minChunkSize'
placeholder='100'
{...register('minChunkSize', { valueAsNumber: true })}
className={cn(errors.minChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='min-chunk-size'
/>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='maxChunkSize'>Max Chunk Size (tokens)</Label>
<Input
id='maxChunkSize'
placeholder='1024'
{...register('maxChunkSize', { valueAsNumber: true })}
className={cn(errors.maxChunkSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='max-chunk-size'
/>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='maxChunkSize'>Max Chunk Size (tokens)</Label>
<Label htmlFor='overlapSize'>Overlap (tokens)</Label>
<Input
id='maxChunkSize'
placeholder='1024'
{...register('maxChunkSize', { valueAsNumber: true })}
className={cn(errors.maxChunkSize && 'border-[var(--text-error)]')}
id='overlapSize'
placeholder='200'
{...register('overlapSize', { valueAsNumber: true })}
className={cn(errors.overlapSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='max-chunk-size'
name='overlap-size'
/>
</div>
</div>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='overlapSize'>Overlap (tokens)</Label>
<Input
id='overlapSize'
placeholder='200'
{...register('overlapSize', { valueAsNumber: true })}
className={cn(errors.overlapSize && 'border-[var(--text-error)]')}
autoComplete='off'
data-form-type='other'
name='overlap-size'
/>
<p className='text-[11px] text-[var(--text-muted)]'>
1 token 4 characters. Max chunk size and overlap are in tokens.
</p>

View File

@@ -59,7 +59,7 @@ export function EditKnowledgeBaseModal({
handleSubmit,
reset,
watch,
formState: { errors, isDirty },
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
@@ -70,6 +70,12 @@ export function EditKnowledgeBaseModal({
})
const nameValue = watch('name')
const descriptionValue = watch('description')
// Check if form values have changed from initial values
const hasChanges =
nameValue?.trim() !== initialName.trim() ||
(descriptionValue?.trim() || '') !== (initialDescription?.trim() || '')
useEffect(() => {
if (open) {
@@ -127,7 +133,7 @@ export function EditKnowledgeBaseModal({
<Textarea
id='description'
placeholder='Describe this knowledge base (optional)'
rows={4}
rows={3}
{...register('description')}
className={cn(errors.description && 'border-[var(--text-error)]')}
/>
@@ -159,9 +165,9 @@ export function EditKnowledgeBaseModal({
Cancel
</Button>
<Button
variant='tertiary'
variant={hasChanges ? 'tertiary' : 'default'}
type='submit'
disabled={isSubmitting || !nameValue?.trim() || !isDirty}
disabled={isSubmitting || !nameValue?.trim() || !hasChanges}
>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>

View File

@@ -265,12 +265,11 @@ export function Knowledge() {
</div>
</div>
) : error ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-[var(--text-secondary)] text-sm'>
Error loading knowledge bases
</p>
<p className='mt-1 text-[var(--text-muted)] text-xs'>{error}</p>
<div className='col-span-full flex h-64 items-center justify-center'>
<div className='text-[var(--text-error)]'>
<span className='text-[13px]'>
Error: {typeof error === 'string' ? error : 'Failed to load knowledge bases'}
</span>
</div>
</div>
) : (

View File

@@ -16,8 +16,8 @@ import {
import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
import {
BlockDetailsSidebar,
getLeftmostBlockId,
PreviewEditor,
WorkflowPreview,
} from '@/app/workspace/[workspaceId]/w/components/preview'
import { useExecutionSnapshot } from '@/hooks/queries/logs'
@@ -248,10 +248,11 @@ export function ExecutionSnapshot({
cursorStyle='pointer'
executedBlocks={blockExecutions}
selectedBlockId={pinnedBlockId}
lightweight
/>
</div>
{pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
<PreviewEditor
<BlockDetailsSidebar
block={workflowState.blocks[pinnedBlockId]}
executionData={blockExecutions[pinnedBlockId]}
allBlockExecutions={blockExecutions}

View File

@@ -2,7 +2,8 @@
import type React from 'react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, Check, Clipboard, Search, X } from 'lucide-react'
import clsx from 'clsx'
import { ArrowDown, ArrowUp, X } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
Button,
@@ -14,11 +15,9 @@ import {
PopoverContent,
PopoverDivider,
PopoverItem,
Tooltip,
} from '@/components/emcn'
import { WorkflowIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { formatDuration } from '@/lib/core/utils/formatting'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getBlock, getBlockByToolName } from '@/blocks'
@@ -27,6 +26,7 @@ import type { TraceSpan } from '@/stores/logs/filters/types'
interface TraceSpansProps {
traceSpans?: TraceSpan[]
totalDuration?: number
}
/**
@@ -100,20 +100,6 @@ function parseTime(value?: string | number | null): number {
return Number.isFinite(ms) ? ms : 0
}
/**
* Checks if a span or any of its descendants has an error
*/
function hasErrorInTree(span: TraceSpan): boolean {
if (span.status === 'error') return true
if (span.children && span.children.length > 0) {
return span.children.some((child) => hasErrorInTree(child))
}
if (span.toolCalls && span.toolCalls.length > 0) {
return span.toolCalls.some((tc) => tc.error)
}
return false
}
/**
* Normalizes and sorts trace spans recursively.
* Merges children from both span.children and span.output.childTraceSpans,
@@ -156,6 +142,14 @@ function normalizeAndSortSpans(spans: TraceSpan[]): TraceSpan[] {
const DEFAULT_BLOCK_COLOR = '#6b7280'
/**
* Formats duration in ms
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
/**
* Gets icon and color for a span type using block config
*/
@@ -236,7 +230,7 @@ function ProgressBar({
}, [span, childSpans, workflowStartTime, totalDuration])
return (
<div className='relative h-[5px] w-full overflow-hidden rounded-[18px] bg-[var(--divider)]'>
<div className='relative mb-[8px] h-[5px] w-full overflow-hidden rounded-[18px] bg-[var(--divider)]'>
{segments.map((segment, index) => (
<div
key={index}
@@ -252,6 +246,143 @@ function ProgressBar({
)
}
interface ExpandableRowHeaderProps {
name: string
duration: number
isError: boolean
isExpanded: boolean
hasChildren: boolean
showIcon: boolean
icon: React.ComponentType<{ className?: string }> | null
bgColor: string
onToggle: () => void
}
/**
* Reusable expandable row header with chevron, icon, name, and duration
*/
function ExpandableRowHeader({
name,
duration,
isError,
isExpanded,
hasChildren,
showIcon,
icon: Icon,
bgColor,
onToggle,
}: ExpandableRowHeaderProps) {
return (
<div
className={clsx('group flex items-center justify-between', hasChildren && 'cursor-pointer')}
onClick={hasChildren ? onToggle : undefined}
onKeyDown={
hasChildren
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
}
}
: undefined
}
role={hasChildren ? 'button' : undefined}
tabIndex={hasChildren ? 0 : undefined}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-label={hasChildren ? (isExpanded ? 'Collapse' : 'Expand') : undefined}
>
<div className='flex items-center gap-[8px]'>
{hasChildren && (
<ChevronDown
className='h-[10px] w-[10px] flex-shrink-0 text-[var(--text-tertiary)] transition-transform duration-100 group-hover:text-[var(--text-primary)]'
style={{ transform: isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' }}
/>
)}
{showIcon && (
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: bgColor }}
>
{Icon && <Icon className={clsx('text-white', '!h-[9px] !w-[9px]')} />}
</div>
)}
<span
className='font-medium text-[12px]'
style={{ color: isError ? 'var(--text-error)' : 'var(--text-secondary)' }}
>
{name}
</span>
</div>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(duration)}
</span>
</div>
)
}
interface SpanContentProps {
span: TraceSpan
spanId: string
isError: boolean
workflowStartTime: number
totalDuration: number
expandedSections: Set<string>
onToggle: (section: string) => void
}
/**
* Reusable component for rendering span content (progress bar + input/output sections)
*/
function SpanContent({
span,
spanId,
isError,
workflowStartTime,
totalDuration,
expandedSections,
onToggle,
}: SpanContentProps) {
const hasInput = Boolean(span.input)
const hasOutput = Boolean(span.output)
return (
<>
<ProgressBar
span={span}
childSpans={span.children}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
{hasInput && (
<InputOutputSection
label='Input'
data={span.input}
isError={false}
spanId={spanId}
sectionType='input'
expandedSections={expandedSections}
onToggle={onToggle}
/>
)}
{hasInput && hasOutput && <div className='border-[var(--border)] border-t border-dashed' />}
{hasOutput && (
<InputOutputSection
label={isError ? 'Error' : 'Output'}
data={span.output}
isError={isError}
spanId={spanId}
sectionType='output'
expandedSections={expandedSections}
onToggle={onToggle}
/>
)}
</>
)
}
/**
* Renders input/output section with collapsible content, context menu, and search
*/
@@ -275,14 +406,16 @@ function InputOutputSection({
const sectionKey = `${spanId}-${sectionType}`
const isExpanded = expandedSections.has(sectionKey)
const contentRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// Context menu state
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const [copied, setCopied] = useState(false)
// Code viewer features
const {
wrapText,
toggleWrapText,
isSearchActive,
searchQuery,
setSearchQuery,
@@ -314,8 +447,6 @@ function InputOutputSection({
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
closeContextMenu()
}, [jsonString, closeContextMenu])
@@ -324,8 +455,13 @@ function InputOutputSection({
closeContextMenu()
}, [activateSearch, closeContextMenu])
const handleToggleWrap = useCallback(() => {
toggleWrapText()
closeContextMenu()
}, [toggleWrapText, closeContextMenu])
return (
<div className='relative flex min-w-0 flex-col gap-[6px] overflow-hidden'>
<div className='relative flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<div
className='group flex cursor-pointer items-center justify-between'
onClick={() => onToggle(sectionKey)}
@@ -341,7 +477,7 @@ function InputOutputSection({
aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${label.toLowerCase()}`}
>
<span
className={cn(
className={clsx(
'font-medium text-[12px] transition-colors',
isError
? 'text-[var(--text-error)]'
@@ -351,7 +487,9 @@ function InputOutputSection({
{label}
</span>
<ChevronDown
className='h-[8px] w-[8px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
className={clsx(
'h-[10px] w-[10px] text-[var(--text-tertiary)] transition-colors transition-transform group-hover:text-[var(--text-primary)]'
)}
style={{
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
@@ -359,57 +497,16 @@ function InputOutputSection({
</div>
{isExpanded && (
<>
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
<div ref={contentRef} onContextMenu={handleContextMenu}>
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
className='!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText={wrapText}
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
{/* Glass action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
{copied ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{/* Search Overlay */}
@@ -482,10 +579,13 @@ function InputOutputSection({
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
<PopoverItem showCheck={wrapText} onClick={handleToggleWrap}>
Wrap Text
</PopoverItem>
</PopoverContent>
</Popover>,
document.body
@@ -496,229 +596,355 @@ function InputOutputSection({
)
}
interface TraceSpanNodeProps {
interface NestedBlockItemProps {
span: TraceSpan
parentId: string
index: number
expandedSections: Set<string>
onToggle: (section: string) => void
workflowStartTime: number
totalDuration: number
depth: number
expandedNodes: Set<string>
expandedSections: Set<string>
onToggleNode: (nodeId: string) => void
onToggleSection: (section: string) => void
expandedChildren: Set<string>
onToggleChildren: (spanId: string) => void
}
/**
* Recursive tree node component for rendering trace spans
* Recursive component for rendering nested blocks at any depth
*/
const TraceSpanNode = memo(function TraceSpanNode({
function NestedBlockItem({
span,
parentId,
index,
expandedSections,
onToggle,
workflowStartTime,
totalDuration,
depth,
expandedNodes,
expandedSections,
onToggleNode,
onToggleSection,
}: TraceSpanNodeProps): React.ReactNode {
expandedChildren,
onToggleChildren,
}: NestedBlockItemProps): React.ReactNode {
const spanId = span.id || `${parentId}-nested-${index}`
const isError = span.status === 'error'
const { icon: SpanIcon, bgColor } = getBlockIconAndColor(span.type, span.name)
const hasChildren = Boolean(span.children && span.children.length > 0)
const isChildrenExpanded = expandedChildren.has(spanId)
return (
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
<ExpandableRowHeader
name={span.name}
duration={span.duration || 0}
isError={isError}
isExpanded={isChildrenExpanded}
hasChildren={hasChildren}
showIcon={!isIterationType(span.type)}
icon={SpanIcon}
bgColor={bgColor}
onToggle={() => onToggleChildren(spanId)}
/>
<SpanContent
span={span}
spanId={spanId}
isError={isError}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedSections={expandedSections}
onToggle={onToggle}
/>
{/* Nested children */}
{hasChildren && isChildrenExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{span.children!.map((child, childIndex) => (
<NestedBlockItem
key={child.id || `${spanId}-child-${childIndex}`}
span={child}
parentId={spanId}
index={childIndex}
expandedSections={expandedSections}
onToggle={onToggle}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedChildren={expandedChildren}
onToggleChildren={onToggleChildren}
/>
))}
</div>
)}
</div>
)
}
interface TraceSpanItemProps {
span: TraceSpan
totalDuration: number
workflowStartTime: number
isFirstSpan?: boolean
}
/**
* Individual trace span card component.
* Memoized to prevent re-renders when sibling spans change.
*/
const TraceSpanItem = memo(function TraceSpanItem({
span,
totalDuration,
workflowStartTime,
isFirstSpan = false,
}: TraceSpanItemProps): React.ReactNode {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
const [expandedChildren, setExpandedChildren] = useState<Set<string>>(new Set())
const [isCardExpanded, setIsCardExpanded] = useState(false)
const toggleSet = useSetToggle()
const spanId = span.id || `span-${span.name}-${span.startTime}`
const spanStartTime = new Date(span.startTime).getTime()
const spanEndTime = new Date(span.endTime).getTime()
const duration = span.duration || spanEndTime - spanStartTime
const isDirectError = span.status === 'error'
const hasNestedError = hasErrorInTree(span)
const showErrorStyle = isDirectError || hasNestedError
const hasChildren = Boolean(span.children && span.children.length > 0)
const hasToolCalls = Boolean(span.toolCalls && span.toolCalls.length > 0)
const isError = span.status === 'error'
const inlineChildTypes = new Set([
'tool',
'model',
'loop-iteration',
'parallel-iteration',
'workflow',
])
// For workflow-in-workflow blocks, all children should be rendered inline/nested
const isWorkflowBlock = span.type?.toLowerCase().includes('workflow')
const inlineChildren = isWorkflowBlock
? span.children || []
: span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const otherChildren = isWorkflowBlock
? []
: span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
const toolCallSpans = useMemo(() => {
if (!hasToolCalls) return []
return span.toolCalls!.map((toolCall, index) => {
const toolStartTime = toolCall.startTime
? new Date(toolCall.startTime).getTime()
: spanStartTime
const toolEndTime = toolCall.endTime
? new Date(toolCall.endTime).getTime()
: toolStartTime + (toolCall.duration || 0)
return {
id: `${spanId}-tool-${index}`,
name: toolCall.name,
type: 'tool',
duration: toolCall.duration || toolEndTime - toolStartTime,
startTime: new Date(toolStartTime).toISOString(),
endTime: new Date(toolEndTime).toISOString(),
status: toolCall.error ? ('error' as const) : ('success' as const),
input: toolCall.input,
output: toolCall.error
? { error: toolCall.error, ...(toolCall.output || {}) }
: toolCall.output,
} as TraceSpan
})
}, [hasToolCalls, span.toolCalls, spanId, spanStartTime])
const handleSectionToggle = useCallback(
(section: string) => toggleSet(setExpandedSections, section),
[toggleSet]
)
const handleChildrenToggle = useCallback(
(childSpanId: string) => toggleSet(setExpandedChildren, childSpanId),
[toggleSet]
)
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type, span.name)
// Root workflow execution is always expanded and has no toggle
const isRootWorkflow = depth === 0
// Check if this card has expandable inline content
const hasInlineContent =
(isWorkflowBlock && inlineChildren.length > 0) ||
(!isWorkflowBlock && (toolCallSpans.length > 0 || inlineChildren.length > 0))
// Build all children including tool calls
const allChildren = useMemo(() => {
const children: TraceSpan[] = []
// Add tool calls as child spans
if (span.toolCalls && span.toolCalls.length > 0) {
span.toolCalls.forEach((toolCall, index) => {
const toolStartTime = toolCall.startTime
? new Date(toolCall.startTime).getTime()
: spanStartTime
const toolEndTime = toolCall.endTime
? new Date(toolCall.endTime).getTime()
: toolStartTime + (toolCall.duration || 0)
children.push({
id: `${spanId}-tool-${index}`,
name: toolCall.name,
type: 'tool',
duration: toolCall.duration || toolEndTime - toolStartTime,
startTime: new Date(toolStartTime).toISOString(),
endTime: new Date(toolEndTime).toISOString(),
status: toolCall.error ? ('error' as const) : ('success' as const),
input: toolCall.input,
output: toolCall.error
? { error: toolCall.error, ...(toolCall.output || {}) }
: toolCall.output,
} as TraceSpan)
})
}
// Add regular children
if (span.children && span.children.length > 0) {
children.push(...span.children)
}
// Sort by start time
return children.sort((a, b) => parseTime(a.startTime) - parseTime(b.startTime))
}, [span, spanId, spanStartTime])
const hasChildren = allChildren.length > 0
const isExpanded = isRootWorkflow || expandedNodes.has(spanId)
const isToggleable = !isRootWorkflow
const hasInput = Boolean(span.input)
const hasOutput = Boolean(span.output)
// For progress bar - show child segments for workflow/iteration types
const lowerType = span.type?.toLowerCase() || ''
const showChildrenInProgressBar =
isIterationType(lowerType) || lowerType === 'workflow' || lowerType === 'workflow_input'
const isExpandable = !isFirstSpan && hasInlineContent
return (
<div className='flex min-w-0 flex-col'>
{/* Node Header Row */}
<div
className={cn(
'group flex items-center justify-between gap-[8px] py-[6px]',
isToggleable && 'cursor-pointer'
<>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
<ExpandableRowHeader
name={span.name}
duration={duration}
isError={isError}
isExpanded={isCardExpanded}
hasChildren={isExpandable}
showIcon={!isFirstSpan}
icon={BlockIcon}
bgColor={bgColor}
onToggle={() => setIsCardExpanded((prev) => !prev)}
/>
<SpanContent
span={span}
spanId={spanId}
isError={isError}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
{/* For workflow blocks, keep children nested within the card (not as separate cards) */}
{!isFirstSpan && isWorkflowBlock && inlineChildren.length > 0 && isCardExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{inlineChildren.map((childSpan, index) => (
<NestedBlockItem
key={childSpan.id || `${spanId}-nested-${index}`}
span={childSpan}
parentId={spanId}
index={index}
expandedSections={expandedSections}
onToggle={handleSectionToggle}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedChildren={expandedChildren}
onToggleChildren={handleChildrenToggle}
/>
))}
</div>
)}
onClick={isToggleable ? () => onToggleNode(spanId) : undefined}
onKeyDown={
isToggleable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggleNode(spanId)
}
}
: undefined
}
role={isToggleable ? 'button' : undefined}
tabIndex={isToggleable ? 0 : undefined}
aria-expanded={isToggleable ? isExpanded : undefined}
aria-label={isToggleable ? (isExpanded ? 'Collapse' : 'Expand') : undefined}
>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
{!isIterationType(span.type) && (
<div
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
style={{ background: bgColor }}
>
{BlockIcon && <BlockIcon className='h-[9px] w-[9px] text-white' />}
</div>
)}
<span
className='min-w-0 max-w-[180px] truncate font-medium text-[12px]'
style={{ color: showErrorStyle ? 'var(--text-error)' : 'var(--text-secondary)' }}
>
{span.name}
</span>
{isToggleable && (
<ChevronDown
className='h-[8px] w-[8px] flex-shrink-0 text-[var(--text-tertiary)] transition-colors transition-transform duration-100 group-hover:text-[var(--text-primary)]'
style={{
transform: `translateY(-0.25px) ${isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)'}`,
}}
/>
)}
</div>
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
{formatDuration(duration, { precision: 2 })}
</span>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className='flex min-w-0 flex-col gap-[10px]'>
{/* Progress Bar */}
<ProgressBar
span={span}
childSpans={showChildrenInProgressBar ? span.children : undefined}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
/>
{/* For non-workflow blocks, render inline children/tool calls */}
{!isFirstSpan && !isWorkflowBlock && isCardExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{[...toolCallSpans, ...inlineChildren].map((childSpan, index) => {
const childId = childSpan.id || `${spanId}-inline-${index}`
const childIsError = childSpan.status === 'error'
const childLowerType = childSpan.type?.toLowerCase() || ''
const hasNestedChildren = Boolean(childSpan.children && childSpan.children.length > 0)
const isNestedExpanded = expandedChildren.has(childId)
const showChildrenInProgressBar =
isIterationType(childLowerType) || childLowerType === 'workflow'
const { icon: ChildIcon, bgColor: childBgColor } = getBlockIconAndColor(
childSpan.type,
childSpan.name
)
{/* Input/Output Sections */}
{(hasInput || hasOutput) && (
<div className='flex min-w-0 flex-col gap-[6px] overflow-hidden py-[2px]'>
{hasInput && (
<InputOutputSection
label='Input'
data={span.input}
isError={false}
spanId={spanId}
sectionType='input'
expandedSections={expandedSections}
onToggle={onToggleSection}
/>
)}
return (
<div
key={`inline-${childId}`}
className='flex min-w-0 flex-col gap-[8px] overflow-hidden'
>
<ExpandableRowHeader
name={childSpan.name}
duration={childSpan.duration || 0}
isError={childIsError}
isExpanded={isNestedExpanded}
hasChildren={hasNestedChildren}
showIcon={!isIterationType(childSpan.type)}
icon={ChildIcon}
bgColor={childBgColor}
onToggle={() => handleChildrenToggle(childId)}
/>
{hasInput && hasOutput && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{hasOutput && (
<InputOutputSection
label={isDirectError ? 'Error' : 'Output'}
data={span.output}
isError={isDirectError}
spanId={spanId}
sectionType='output'
expandedSections={expandedSections}
onToggle={onToggleSection}
/>
)}
</div>
)}
{/* Nested Children */}
{hasChildren && (
<div className='flex min-w-0 flex-col gap-[2px] border-[var(--border)] border-l pl-[10px]'>
{allChildren.map((child, index) => (
<div key={child.id || `${spanId}-child-${index}`} className='pl-[6px]'>
<TraceSpanNode
span={child}
<ProgressBar
span={childSpan}
childSpans={showChildrenInProgressBar ? childSpan.children : undefined}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
depth={depth + 1}
expandedNodes={expandedNodes}
expandedSections={expandedSections}
onToggleNode={onToggleNode}
onToggleSection={onToggleSection}
/>
{childSpan.input && (
<InputOutputSection
label='Input'
data={childSpan.input}
isError={false}
spanId={childId}
sectionType='input'
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
)}
{childSpan.input && childSpan.output && (
<div className='border-[var(--border)] border-t border-dashed' />
)}
{childSpan.output && (
<InputOutputSection
label={childIsError ? 'Error' : 'Output'}
data={childSpan.output}
isError={childIsError}
spanId={childId}
sectionType='output'
expandedSections={expandedSections}
onToggle={handleSectionToggle}
/>
)}
{/* Nested children */}
{showChildrenInProgressBar && hasNestedChildren && isNestedExpanded && (
<div className='mt-[2px] flex min-w-0 flex-col gap-[10px] overflow-hidden border-[var(--border)] border-l pl-[10px]'>
{childSpan.children!.map((nestedChild, nestedIndex) => (
<NestedBlockItem
key={nestedChild.id || `${childId}-nested-${nestedIndex}`}
span={nestedChild}
parentId={childId}
index={nestedIndex}
expandedSections={expandedSections}
onToggle={handleSectionToggle}
workflowStartTime={workflowStartTime}
totalDuration={totalDuration}
expandedChildren={expandedChildren}
onToggleChildren={handleChildrenToggle}
/>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* For the first span (workflow execution), render all children as separate top-level cards */}
{isFirstSpan &&
hasChildren &&
span.children!.map((childSpan, index) => (
<TraceSpanItem
key={childSpan.id || `${spanId}-child-${index}`}
span={childSpan}
totalDuration={totalDuration}
workflowStartTime={workflowStartTime}
isFirstSpan={false}
/>
))}
{!isFirstSpan &&
otherChildren.map((childSpan, index) => (
<TraceSpanItem
key={childSpan.id || `${spanId}-other-${index}`}
span={childSpan}
totalDuration={totalDuration}
workflowStartTime={workflowStartTime}
isFirstSpan={false}
/>
))}
</>
)
})
/**
* Displays workflow execution trace spans with nested tree structure.
* Displays workflow execution trace spans with nested structure.
* Memoized to prevent re-renders when parent LogDetails updates.
*/
export const TraceSpans = memo(function TraceSpans({ traceSpans }: TraceSpansProps) {
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set())
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
const toggleSet = useSetToggle()
export const TraceSpans = memo(function TraceSpans({
traceSpans,
totalDuration = 0,
}: TraceSpansProps) {
const { workflowStartTime, actualTotalDuration, normalizedSpans } = useMemo(() => {
if (!traceSpans || traceSpans.length === 0) {
return { workflowStartTime: 0, actualTotalDuration: 0, normalizedSpans: [] }
return { workflowStartTime: 0, actualTotalDuration: totalDuration, normalizedSpans: [] }
}
let earliest = Number.POSITIVE_INFINITY
@@ -736,37 +962,26 @@ export const TraceSpans = memo(function TraceSpans({ traceSpans }: TraceSpansPro
actualTotalDuration: latest - earliest,
normalizedSpans: normalizeAndSortSpans(traceSpans),
}
}, [traceSpans])
const handleToggleNode = useCallback(
(nodeId: string) => toggleSet(setExpandedNodes, nodeId),
[toggleSet]
)
const handleToggleSection = useCallback(
(section: string) => toggleSet(setExpandedSections, section),
[toggleSet]
)
}, [traceSpans, totalDuration])
if (!traceSpans || traceSpans.length === 0) {
return <div className='text-[12px] text-[var(--text-secondary)]'>No trace data available</div>
}
return (
<div className='flex w-full min-w-0 flex-col overflow-hidden'>
{normalizedSpans.map((span, index) => (
<TraceSpanNode
key={span.id || index}
span={span}
workflowStartTime={workflowStartTime}
totalDuration={actualTotalDuration}
depth={0}
expandedNodes={expandedNodes}
expandedSections={expandedSections}
onToggleNode={handleToggleNode}
onToggleSection={handleToggleSection}
/>
))}
<div className='flex w-full min-w-0 flex-col gap-[6px] overflow-hidden rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Trace Span</span>
<div className='flex min-w-0 flex-col gap-[8px] overflow-hidden'>
{normalizedSpans.map((span, index) => (
<TraceSpanItem
key={span.id || index}
span={span}
totalDuration={actualTotalDuration}
workflowStartTime={workflowStartTime}
isFirstSpan={index === 0}
/>
))}
</div>
</div>
)
})

View File

@@ -1,23 +1,10 @@
'use client'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowDown, ArrowUp, Check, ChevronUp, Clipboard, Search, X } from 'lucide-react'
import { createPortal } from 'react-dom'
import {
Button,
Code,
Eye,
Input,
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
Tooltip,
} from '@/components/emcn'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronUp, X } from 'lucide-react'
import { Button, Eye } from '@/components/emcn'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
import { cn } from '@/lib/core/utils/cn'
import {
ExecutionSnapshot,
FileCards,
@@ -30,194 +17,11 @@ import {
StatusBadge,
TriggerBadge,
} from '@/app/workspace/[workspaceId]/logs/utils'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { formatCost } from '@/providers/utils'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import { useLogDetailsUIStore } from '@/stores/logs/store'
/**
* Workflow Output section with code viewer, copy, search, and context menu functionality
*/
function WorkflowOutputSection({ output }: { output: Record<string, unknown> }) {
const contentRef = useRef<HTMLDivElement>(null)
const [copied, setCopied] = useState(false)
// Context menu state
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
const {
isSearchActive,
searchQuery,
setSearchQuery,
matchCount,
currentMatchIndex,
activateSearch,
closeSearch,
goToNextMatch,
goToPreviousMatch,
handleMatchCountChange,
searchInputRef,
} = useCodeViewerFeatures({ contentRef })
const jsonString = useMemo(() => JSON.stringify(output, null, 2), [output])
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setContextMenuPosition({ x: e.clientX, y: e.clientY })
setIsContextMenuOpen(true)
}, [])
const closeContextMenu = useCallback(() => {
setIsContextMenuOpen(false)
}, [])
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(jsonString)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
closeContextMenu()
}, [jsonString, closeContextMenu])
const handleSearch = useCallback(() => {
activateSearch()
closeContextMenu()
}, [activateSearch, closeContextMenu])
return (
<div className='relative flex min-w-0 flex-col overflow-hidden'>
<div ref={contentRef} onContextMenu={handleContextMenu} className='relative'>
<Code.Viewer
code={jsonString}
language='json'
className='!bg-[var(--surface-4)] dark:!bg-[var(--surface-3)] max-h-[300px] min-h-0 max-w-full rounded-[6px] border-0 [word-break:break-all]'
wrapText
searchQuery={isSearchActive ? searchQuery : undefined}
currentMatchIndex={currentMatchIndex}
onMatchCountChange={handleMatchCountChange}
/>
{/* Glass action buttons overlay */}
{!isSearchActive && (
<div className='absolute top-[7px] right-[6px] z-10 flex gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
{copied ? (
<Check className='h-[10px] w-[10px] text-[var(--text-success)]' />
) : (
<Clipboard className='h-[10px] w-[10px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{copied ? 'Copied' : 'Copy'}</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='default'
onClick={(e) => {
e.stopPropagation()
activateSearch()
}}
className='h-[20px] w-[20px] cursor-pointer border border-[var(--border-1)] bg-transparent p-0 backdrop-blur-sm hover:bg-[var(--surface-3)]'
>
<Search className='h-[10px] w-[10px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>Search</Tooltip.Content>
</Tooltip.Root>
</div>
)}
</div>
{/* Search Overlay */}
{isSearchActive && (
<div
className='absolute top-0 right-0 z-30 flex h-[34px] items-center gap-[6px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-1)] px-[6px] shadow-sm'
onClick={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
type='text'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder='Search...'
className='mr-[2px] h-[23px] w-[94px] text-[12px]'
/>
<span
className={cn(
'min-w-[45px] text-center text-[11px]',
matchCount > 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]'
)}
>
{matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'}
</span>
<Button
variant='ghost'
className='!p-1'
onClick={goToPreviousMatch}
disabled={matchCount === 0}
aria-label='Previous match'
>
<ArrowUp className='h-[12px] w-[12px]' />
</Button>
<Button
variant='ghost'
className='!p-1'
onClick={goToNextMatch}
disabled={matchCount === 0}
aria-label='Next match'
>
<ArrowDown className='h-[12px] w-[12px]' />
</Button>
<Button variant='ghost' className='!p-1' onClick={closeSearch} aria-label='Close search'>
<X className='h-[12px] w-[12px]' />
</Button>
</div>
)}
{/* Context Menu - rendered in portal to avoid transform/overflow clipping */}
{typeof document !== 'undefined' &&
createPortal(
<Popover
open={isContextMenuOpen}
onOpenChange={closeContextMenu}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenuPosition.x}px`,
top: `${contextMenuPosition.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={handleCopy}>Copy</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={handleSearch}>Search</PopoverItem>
</PopoverContent>
</Popover>,
document.body
)}
</div>
)
}
interface LogDetailsProps {
/** The log to display details for */
log: WorkflowLog | null
@@ -274,18 +78,6 @@ export const LogDetails = memo(function LogDetails({
return isWorkflowExecutionLog && log?.cost
}, [log, isWorkflowExecutionLog])
// Extract and clean the workflow final output (remove childTraceSpans for cleaner display)
const workflowOutput = useMemo(() => {
const executionData = log?.executionData as
| { finalOutput?: Record<string, unknown> }
| undefined
if (!executionData?.finalOutput) return null
const { childTraceSpans, ...cleanOutput } = executionData.finalOutput as {
childTraceSpans?: unknown
} & Record<string, unknown>
return cleanOutput
}, [log?.executionData])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
@@ -295,12 +87,12 @@ export const LogDetails = memo(function LogDetails({
if (isOpen) {
if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) {
e.preventDefault()
onNavigatePrev()
handleNavigate(onNavigatePrev)
}
if (e.key === 'ArrowDown' && hasNext && onNavigateNext) {
e.preventDefault()
onNavigateNext()
handleNavigate(onNavigateNext)
}
}
}
@@ -309,6 +101,10 @@ export const LogDetails = memo(function LogDetails({
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])
const handleNavigate = (navigateFunction: () => void) => {
navigateFunction()
}
const formattedTimestamp = useMemo(
() => (log ? formatDate(log.createdAt) : null),
[log?.createdAt]
@@ -346,7 +142,7 @@ export const LogDetails = memo(function LogDetails({
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasPrev && onNavigatePrev?.()}
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
disabled={!hasPrev}
aria-label='Previous log'
>
@@ -355,7 +151,7 @@ export const LogDetails = memo(function LogDetails({
<Button
variant='ghost'
className='!p-[4px]'
onClick={() => hasNext && onNavigateNext?.()}
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
disabled={!hasNext}
aria-label='Next log'
>
@@ -408,7 +204,7 @@ export const LogDetails = memo(function LogDetails({
{/* Execution ID */}
{log.executionId && (
<div className='flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Execution ID
</span>
@@ -419,7 +215,7 @@ export const LogDetails = memo(function LogDetails({
)}
{/* Details Section */}
<div className='-my-[4px] flex min-w-0 flex-col overflow-hidden'>
<div className='flex min-w-0 flex-col overflow-hidden'>
{/* Level */}
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
@@ -471,35 +267,19 @@ export const LogDetails = memo(function LogDetails({
{/* Workflow State */}
{isWorkflowExecutionLog && log.executionId && !permissionConfig.hideTraceSpans && (
<div className='-mt-[8px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Workflow State
</span>
<Button
variant='active'
<button
onClick={() => setIsExecutionSnapshotOpen(true)}
className='flex w-full items-center justify-between px-[10px] py-[6px]'
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--surface-4)]'
>
<span className='font-medium text-[12px]'>View Snapshot</span>
<Eye className='h-[14px] w-[14px]' />
</Button>
</div>
)}
{/* Workflow Output */}
{isWorkflowExecutionLog && workflowOutput && !permissionConfig.hideTraceSpans && (
<div className='mt-[4px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px] dark:bg-transparent'>
<span
className={cn(
'font-medium text-[12px]',
workflowOutput.error
? 'text-[var(--text-error)]'
: 'text-[var(--text-tertiary)]'
)}
>
Workflow Output
</span>
<WorkflowOutputSection output={workflowOutput} />
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
View Snapshot
</span>
<Eye className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
</button>
</div>
)}
@@ -507,12 +287,10 @@ export const LogDetails = memo(function LogDetails({
{isWorkflowExecutionLog &&
log.executionData?.traceSpans &&
!permissionConfig.hideTraceSpans && (
<div className='mt-[4px] flex flex-col gap-[6px] rounded-[6px] border border-[var(--border)] bg-[var(--surface-2)] px-[10px] py-[8px] dark:bg-transparent'>
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
Trace Span
</span>
<TraceSpans traceSpans={log.executionData.traceSpans} />
</div>
<TraceSpans
traceSpans={log.executionData.traceSpans}
totalDuration={log.executionData.totalDuration}
/>
)}
{/* Files */}

View File

@@ -94,9 +94,7 @@ export default function Logs() {
const [previewLogId, setPreviewLogId] = useState<string | null>(null)
const activeLogId = isPreviewOpen ? previewLogId : selectedLogId
const activeLogQuery = useLogDetail(activeLogId ?? undefined, {
refetchInterval: isLive ? 3000 : false,
})
const activeLogQuery = useLogDetail(activeLogId ?? undefined)
const logFilters = useMemo(
() => ({
@@ -115,7 +113,7 @@ export default function Logs() {
const logsQuery = useLogsList(workspaceId, logFilters, {
enabled: Boolean(workspaceId) && isInitialized.current,
refetchInterval: isLive ? 3000 : false,
refetchInterval: isLive ? 5000 : false,
})
const dashboardFilters = useMemo(
@@ -134,7 +132,7 @@ export default function Logs() {
const dashboardStatsQuery = useDashboardStats(workspaceId, dashboardFilters, {
enabled: Boolean(workspaceId) && isInitialized.current,
refetchInterval: isLive ? 3000 : false,
refetchInterval: isLive ? 5000 : false,
})
const logs = useMemo(() => {
@@ -162,6 +160,12 @@ export default function Logs() {
}
}, [debouncedSearchQuery, setStoreSearchQuery])
useEffect(() => {
if (!isLive || !selectedLogId) return
const interval = setInterval(() => activeLogQuery.refetch(), 5000)
return () => clearInterval(interval)
}, [isLive, selectedLogId, activeLogQuery])
const handleLogClick = useCallback(
(log: WorkflowLog) => {
if (selectedLogId === log.id && isSidebarOpen) {
@@ -275,11 +279,8 @@ export default function Logs() {
setIsVisuallyRefreshing(true)
setTimeout(() => setIsVisuallyRefreshing(false), REFRESH_SPINNER_DURATION_MS)
logsQuery.refetch()
if (selectedLogId) {
activeLogQuery.refetch()
}
}
}, [isLive, logsQuery, activeLogQuery, selectedLogId])
}, [isLive, logsQuery])
const prevIsFetchingRef = useRef(logsQuery.isFetching)
useEffect(() => {

View File

@@ -0,0 +1,72 @@
import { Plus } from 'lucide-react'
import { Button, TableCell, TableRow } from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
import type { ColumnDefinition } from '@/lib/table'
interface LoadingRowsProps {
columns: ColumnDefinition[]
}
export function LoadingRows({ columns }: LoadingRowsProps) {
return (
<>
{Array.from({ length: 25 }).map((_, rowIndex) => (
<TableRow key={rowIndex}>
<TableCell>
<Skeleton className='h-[14px] w-[14px]' />
</TableCell>
{columns.map((col, colIndex) => {
const baseWidth =
col.type === 'json'
? 200
: col.type === 'string'
? 160
: col.type === 'number'
? 80
: col.type === 'boolean'
? 50
: col.type === 'date'
? 100
: 120
const variation = ((rowIndex + colIndex) % 3) * 20
const width = baseWidth + variation
return (
<TableCell key={col.name}>
<Skeleton className='h-[16px]' style={{ width: `${width}px` }} />
</TableCell>
)
})}
</TableRow>
))}
</>
)
}
interface EmptyRowsProps {
columnCount: number
hasFilter: boolean
onAddRow: () => void
}
export function EmptyRows({ columnCount, hasFilter, onAddRow }: EmptyRowsProps) {
return (
<TableRow>
<TableCell colSpan={columnCount + 1} className='h-[160px]'>
<div className='flex h-full w-full items-center justify-center'>
<div className='flex flex-col items-center gap-[12px]'>
<span className='text-[13px] text-[var(--text-tertiary)]'>
{hasFilter ? 'No rows match your filter' : 'No data'}
</span>
{!hasFilter && (
<Button variant='default' size='sm' onClick={onAddRow}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add first row
</Button>
)}
</div>
</div>
</TableCell>
</TableRow>
)
}

View File

@@ -0,0 +1,99 @@
import type { ColumnDefinition } from '@/lib/table'
import { STRING_TRUNCATE_LENGTH } from '../lib/constants'
import type { CellViewerData } from '../lib/types'
interface CellRendererProps {
value: unknown
column: ColumnDefinition
onCellClick: (columnName: string, value: unknown, type: CellViewerData['type']) => void
}
export function CellRenderer({ value, column, onCellClick }: CellRendererProps) {
const isNull = value === null || value === undefined
if (isNull) {
return <span className='text-[var(--text-muted)] italic'></span>
}
if (column.type === 'json') {
const jsonStr = JSON.stringify(value)
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate rounded-[4px] border border-[var(--border-1)] px-[6px] py-[2px] text-left font-mono text-[11px] text-[var(--text-secondary)] transition-colors hover:border-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'json')
}}
title='Click to view full JSON'
>
{jsonStr}
</button>
)
}
if (column.type === 'boolean') {
const boolValue = Boolean(value)
return (
<span className={boolValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'}>
{boolValue ? 'true' : 'false'}
</span>
)
}
if (column.type === 'number') {
return (
<span className='font-mono text-[12px] text-[var(--text-secondary)]'>{String(value)}</span>
)
}
if (column.type === 'date') {
try {
const date = new Date(String(value))
const formatted = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
return (
<button
type='button'
className='cursor-pointer select-none text-left text-[12px] text-[var(--text-secondary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:text-[var(--text-primary)] hover:decoration-[var(--text-muted)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'date')
}}
title='Click to view ISO format'
>
{formatted}
</button>
)
} catch {
return <span className='text-[var(--text-primary)]'>{String(value)}</span>
}
}
const strValue = String(value)
if (strValue.length > STRING_TRUNCATE_LENGTH) {
return (
<button
type='button'
className='block max-w-[300px] cursor-pointer select-none truncate text-left text-[var(--text-primary)] underline decoration-[var(--border-1)] decoration-dotted underline-offset-2 transition-colors hover:decoration-[var(--text-muted)]'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onCellClick(column.name, value, 'text')
}}
title='Click to view full text'
>
{strValue}
</button>
)
}
return <span className='text-[var(--text-primary)]'>{strValue}</span>
}

View File

@@ -0,0 +1,84 @@
import { Copy, X } from 'lucide-react'
import { Badge, Button, Modal, ModalBody, ModalContent } from '@/components/emcn'
import type { CellViewerData } from '../lib/types'
interface CellViewerModalProps {
cellViewer: CellViewerData | null
onClose: () => void
onCopy: () => void
copied: boolean
}
export function CellViewerModal({ cellViewer, onClose, onCopy, copied }: CellViewerModalProps) {
if (!cellViewer) return null
return (
<Modal open={!!cellViewer} onOpenChange={(open) => !open && onClose()}>
<ModalContent className='w-[640px] duration-100'>
<div className='flex items-center justify-between gap-[8px] px-[16px] py-[10px]'>
<div className='flex min-w-0 items-center gap-[8px]'>
<span className='truncate font-medium text-[14px] text-[var(--text-primary)]'>
{cellViewer.columnName}
</span>
<Badge
variant={
cellViewer.type === 'json' ? 'blue' : cellViewer.type === 'date' ? 'purple' : 'gray'
}
size='sm'
>
{cellViewer.type === 'json' ? 'JSON' : cellViewer.type === 'date' ? 'Date' : 'Text'}
</Badge>
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
<Button variant={copied ? 'tertiary' : 'default'} size='sm' onClick={onCopy}>
<Copy className='mr-[4px] h-[12px] w-[12px]' />
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button variant='ghost' size='sm' onClick={onClose}>
<X className='h-[14px] w-[14px]' />
</Button>
</div>
</div>
<ModalBody className='p-0'>
{cellViewer.type === 'json' ? (
<pre className='m-[16px] max-h-[450px] overflow-auto rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] font-mono text-[12px] text-[var(--text-primary)] leading-[1.6]'>
{JSON.stringify(cellViewer.value, null, 2)}
</pre>
) : cellViewer.type === 'date' ? (
<div className='m-[16px] space-y-[12px]'>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
Formatted
</div>
<div className='text-[14px] text-[var(--text-primary)]'>
{new Date(String(cellViewer.value)).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
})}
</div>
</div>
<div className='rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px]'>
<div className='mb-[6px] font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
ISO Format
</div>
<div className='font-mono text-[13px] text-[var(--text-secondary)]'>
{String(cellViewer.value)}
</div>
</div>
</div>
) : (
<div className='m-[16px] max-h-[450px] overflow-auto whitespace-pre-wrap break-words rounded-[6px] border border-[var(--border)] bg-[var(--surface-4)] p-[16px] text-[13px] text-[var(--text-primary)] leading-[1.7]'>
{String(cellViewer.value)}
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,49 @@
import { Edit, Trash2 } from 'lucide-react'
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDivider,
PopoverItem,
} from '@/components/emcn'
import type { ContextMenuState } from '../lib/types'
interface ContextMenuProps {
contextMenu: ContextMenuState
onClose: () => void
onEdit: () => void
onDelete: () => void
}
export function ContextMenu({ contextMenu, onClose, onEdit, onDelete }: ContextMenuProps) {
return (
<Popover
open={contextMenu.isOpen}
onOpenChange={(open) => !open && onClose()}
variant='secondary'
size='sm'
colorScheme='inverted'
>
<PopoverAnchor
style={{
position: 'fixed',
left: `${contextMenu.position.x}px`,
top: `${contextMenu.position.y}px`,
width: '1px',
height: '1px',
}}
/>
<PopoverContent align='start' side='bottom' sideOffset={4}>
<PopoverItem onClick={onEdit}>
<Edit className='mr-[8px] h-[12px] w-[12px]' />
Edit row
</PopoverItem>
<PopoverDivider />
<PopoverItem onClick={onDelete} className='text-[var(--text-error)]'>
<Trash2 className='mr-[8px] h-[12px] w-[12px]' />
Delete row
</PopoverItem>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,207 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { Checkbox, Input, Textarea } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition } from '@/lib/table'
interface EditableCellProps {
value: unknown
column: ColumnDefinition
onChange: (value: unknown) => void
isEditing?: boolean
isNew?: boolean
}
function formatValueForDisplay(value: unknown, type: string): string {
if (value === null || value === undefined) return 'NULL'
if (type === 'json') {
return typeof value === 'string' ? value : JSON.stringify(value)
}
if (type === 'boolean') {
return value ? 'TRUE' : 'FALSE'
}
if (type === 'date' && value) {
try {
const date = new Date(String(value))
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return String(value)
}
}
return String(value)
}
function formatValueForInput(value: unknown, type: string): string {
if (value === null || value === undefined) return ''
if (type === 'json') {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
if (type === 'date' && value) {
try {
const date = new Date(String(value))
return date.toISOString().split('T')[0]
} catch {
return String(value)
}
}
return String(value)
}
export function EditableCell({
value,
column,
onChange,
isEditing = false,
isNew = false,
}: EditableCellProps) {
const [localValue, setLocalValue] = useState<unknown>(value)
const [isActive, setIsActive] = useState(false)
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
useEffect(() => {
setLocalValue(value)
}, [value])
useEffect(() => {
if (isActive && inputRef.current) {
inputRef.current.focus()
}
}, [isActive])
const handleFocus = useCallback(() => {
setIsActive(true)
}, [])
const handleBlur = useCallback(() => {
setIsActive(false)
if (localValue !== value) {
onChange(localValue)
}
}, [localValue, value, onChange])
const handleChange = useCallback((newValue: unknown) => {
setLocalValue(newValue)
}, [])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && column.type !== 'json') {
e.preventDefault()
;(e.target as HTMLElement).blur()
}
if (e.key === 'Escape') {
setLocalValue(value)
;(e.target as HTMLElement).blur()
}
},
[value, column.type]
)
const isNull = value === null || value === undefined
// Boolean type - always show checkbox
if (column.type === 'boolean') {
return (
<div className='flex items-center'>
<Checkbox
size='sm'
checked={Boolean(localValue)}
onCheckedChange={(checked) => {
const newValue = checked === true
setLocalValue(newValue)
onChange(newValue)
}}
/>
<span
className={cn(
'ml-[8px] text-[12px]',
localValue ? 'text-green-500' : 'text-[var(--text-tertiary)]'
)}
>
{localValue ? 'TRUE' : 'FALSE'}
</span>
</div>
)
}
// JSON type - use textarea
if (column.type === 'json') {
if (isActive || isNew) {
return (
<Textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
value={formatValueForInput(localValue, column.type)}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className='h-[60px] min-w-[200px] resize-none font-mono text-[11px]'
placeholder='{"key": "value"}'
/>
)
}
return (
<button
type='button'
onClick={handleFocus}
className={cn(
'group flex max-w-[300px] cursor-pointer items-center truncate text-left font-mono text-[11px] transition-colors',
isNull
? 'text-[var(--text-muted)] italic'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
)}
>
<span className='truncate'>{formatValueForDisplay(value, column.type)}</span>
<ChevronRight className='ml-[4px] h-[10px] w-[10px] opacity-0 group-hover:opacity-100' />
</button>
)
}
// Active/editing state for other types
if (isActive || isNew) {
return (
<Input
ref={inputRef as React.RefObject<HTMLInputElement>}
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
value={formatValueForInput(localValue, column.type)}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className={cn(
'h-[28px] min-w-[120px] text-[12px]',
column.type === 'number' && 'font-mono'
)}
placeholder={isNull ? 'NULL' : ''}
/>
)
}
// Display state
return (
<button
type='button'
onClick={handleFocus}
className={cn(
'group flex max-w-[300px] cursor-pointer items-center truncate text-left text-[13px] transition-colors',
isNull
? 'text-[var(--text-muted)] italic'
: column.type === 'number'
? 'font-mono text-[12px] text-[var(--text-secondary)]'
: 'text-[var(--text-primary)]'
)}
>
<span className='truncate'>{formatValueForDisplay(value, column.type)}</span>
<ChevronRight className='ml-[4px] h-[10px] w-[10px] opacity-0 group-hover:opacity-100' />
</button>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { X } from 'lucide-react'
import { Button, TableCell, TableRow } from '@/components/emcn'
import type { ColumnDefinition } from '@/lib/table'
import type { TempRow } from '../hooks/use-inline-editing'
import { EditableCell } from './editable-cell'
interface EditableRowProps {
row: TempRow
columns: ColumnDefinition[]
onUpdateCell: (tempId: string, column: string, value: unknown) => void
onRemove: (tempId: string) => void
}
export function EditableRow({ row, columns, onUpdateCell, onRemove }: EditableRowProps) {
return (
<TableRow className='bg-amber-500/20 hover:bg-amber-500/30'>
<TableCell className='w-[40px]'>
<Button
variant='ghost'
size='sm'
onClick={() => onRemove(row.tempId)}
className='h-[20px] w-[20px] p-0 text-[var(--text-tertiary)] hover:text-[var(--text-primary)]'
>
<X className='h-[12px] w-[12px]' />
</Button>
</TableCell>
{columns.map((column) => (
<TableCell key={column.name}>
<EditableCell
value={row.data[column.name]}
column={column}
onChange={(value) => onUpdateCell(row.tempId, column.name, value)}
isNew
/>
</TableCell>
))}
</TableRow>
)
}

View File

@@ -0,0 +1,169 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, X } from 'lucide-react'
import { Button, Combobox, Input } from '@/components/emcn'
import type { FilterRule } from '@/lib/table/query-builder/constants'
import { filterRulesToFilter } from '@/lib/table/query-builder/converters'
import { useFilterBuilder } from '@/lib/table/query-builder/use-query-builder'
import type { ColumnDefinition } from '@/lib/table/types'
import type { QueryOptions } from '../lib/types'
type Column = Pick<ColumnDefinition, 'name' | 'type'>
interface FilterPanelProps {
columns: Column[]
isVisible: boolean
onApply: (options: QueryOptions) => void
onClose: () => void
isLoading?: boolean
}
// Operators that don't need a value input
const NO_VALUE_OPERATORS = ['is_null', 'is_not_null']
// Options for the first filter row
const WHERE_OPTIONS = [{ value: 'where', label: 'where' }]
export function FilterPanel({
columns,
isVisible,
onApply,
onClose,
isLoading = false,
}: FilterPanelProps) {
const [rules, setRules] = useState<FilterRule[]>([])
const columnOptions = useMemo(
() => columns.map((col) => ({ value: col.name, label: col.name })),
[columns]
)
const {
comparisonOptions,
logicalOptions,
addRule: handleAddRule,
removeRule: handleRemoveRule,
updateRule: handleUpdateRule,
} = useFilterBuilder({
columns: columnOptions,
rules,
setRules,
})
// Auto-add first filter when panel opens with no filters
useEffect(() => {
if (isVisible && rules.length === 0 && columns.length > 0) {
handleAddRule()
}
}, [isVisible, rules.length, columns.length, handleAddRule])
const handleApply = useCallback(() => {
const filter = filterRulesToFilter(rules)
onApply({ filter, sort: null })
}, [rules, onApply])
const handleClear = useCallback(() => {
setRules([])
onApply({ filter: null, sort: null })
onClose()
}, [onApply, onClose])
if (!isVisible) {
return null
}
return (
<div className='flex shrink-0 flex-col gap-2 border-[var(--border)] border-b px-4 py-3'>
{rules.map((rule, index) => {
const needsValue = !NO_VALUE_OPERATORS.includes(rule.operator)
const isFirst = index === 0
return (
<div key={rule.id} className='flex items-center gap-2'>
{/* Remove button */}
<Button
variant='ghost'
size='sm'
onClick={() => handleRemoveRule(rule.id)}
aria-label='Remove filter'
className='shrink-0 p-1'
>
<X className='h-3.5 w-3.5' />
</Button>
{/* Where / And / Or */}
<div className='w-20 shrink-0'>
{isFirst ? (
<Combobox size='sm' options={WHERE_OPTIONS} value='where' onChange={() => {}} />
) : (
<Combobox
size='sm'
options={logicalOptions}
value={rule.logicalOperator}
onChange={(value) =>
handleUpdateRule(rule.id, 'logicalOperator', value as 'and' | 'or')
}
/>
)}
</div>
{/* Column */}
<div className='w-[140px] shrink-0'>
<Combobox
size='sm'
options={columnOptions}
value={rule.column}
onChange={(value) => handleUpdateRule(rule.id, 'column', value)}
placeholder='Column'
/>
</div>
{/* Operator */}
<div className='w-[120px] shrink-0'>
<Combobox
size='sm'
options={comparisonOptions}
value={rule.operator}
onChange={(value) => handleUpdateRule(rule.id, 'operator', value)}
/>
</div>
{/* Value (only if operator needs it) */}
{needsValue && (
<Input
className='w-[160px] shrink-0'
value={rule.value}
onChange={(e) => handleUpdateRule(rule.id, 'value', e.target.value)}
placeholder='Enter a value'
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleApply()
}
}}
/>
)}
{/* Actions - only on first row */}
{isFirst && (
<div className='ml-1 flex items-center gap-1'>
<Button variant='tertiary' size='sm' onClick={handleApply} disabled={isLoading}>
Apply
</Button>
<Button variant='ghost' size='sm' onClick={handleAddRule}>
<Plus className='h-3 w-3' />
Add filter
</Button>
<Button variant='ghost' size='sm' onClick={handleClear}>
Clear filters
</Button>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,11 @@
export * from './body-states'
export * from './cell-renderer'
export * from './cell-viewer-modal'
export * from './context-menu'
export * from './editable-cell'
export * from './editable-row'
export * from './filter-panel'
export * from './row-modal'
export * from './schema-modal'
export * from './table-toolbar'
export * from './table-viewer'

View File

@@ -0,0 +1,399 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useParams } from 'next/navigation'
import {
Badge,
Button,
Checkbox,
Input,
Label,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Textarea,
} from '@/components/emcn'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
const logger = createLogger('RowModal')
export interface RowModalProps {
mode: 'add' | 'edit' | 'delete'
isOpen: boolean
onClose: () => void
table: TableInfo
row?: TableRow
rowIds?: string[]
onSuccess: () => void
}
function createInitialRowData(columns: ColumnDefinition[]): Record<string, unknown> {
const initial: Record<string, unknown> = {}
columns.forEach((col) => {
if (col.type === 'boolean') {
initial[col.name] = false
} else {
initial[col.name] = ''
}
})
return initial
}
function cleanRowData(
columns: ColumnDefinition[],
rowData: Record<string, unknown>
): Record<string, unknown> {
const cleanData: Record<string, unknown> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.type === 'number') {
cleanData[col.name] = value === '' ? null : Number(value)
} else if (col.type === 'json') {
if (typeof value === 'string') {
if (value === '') {
cleanData[col.name] = null
} else {
try {
cleanData[col.name] = JSON.parse(value)
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
}
} else {
cleanData[col.name] = value
}
} else if (col.type === 'boolean') {
cleanData[col.name] = Boolean(value)
} else {
cleanData[col.name] = value || null
}
})
return cleanData
}
function formatValueForInput(value: unknown, type: string): string {
if (value === null || value === undefined) return ''
if (type === 'json') {
return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
}
if (type === 'date' && value) {
try {
const date = new Date(String(value))
return date.toISOString().split('T')[0]
} catch {
return String(value)
}
}
return String(value)
}
function isFieldEmpty(value: unknown, type: string): boolean {
if (value === null || value === undefined) return true
if (type === 'boolean') return false // booleans always have a value (true/false)
if (typeof value === 'string') return value.trim() === ''
return false
}
export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess }: RowModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const schema = table?.schema
const columns = schema?.columns || []
const [rowData, setRowData] = useState<Record<string, unknown>>({})
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// Check if all required fields are filled
const hasRequiredFields = useMemo(() => {
const requiredColumns = columns.filter((col) => col.required)
return requiredColumns.every((col) => !isFieldEmpty(rowData[col.name], col.type))
}, [columns, rowData])
// Initialize form data based on mode
useEffect(() => {
if (!isOpen) return
if (mode === 'add' && columns.length > 0) {
setRowData(createInitialRowData(columns))
} else if (mode === 'edit' && row) {
setRowData(row.data)
}
}, [isOpen, mode, columns, row])
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
const cleanData = cleanRowData(columns, rowData)
if (mode === 'add') {
const res = await fetch(`/api/table/${table?.id}/rows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, data: cleanData }),
})
const result: { error?: string } = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Failed to add row')
}
} else if (mode === 'edit' && row) {
const res = await fetch(`/api/table/${table?.id}/rows/${row.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, data: cleanData }),
})
const result: { error?: string } = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Failed to update row')
}
}
onSuccess()
} catch (err) {
logger.error(`Failed to ${mode} row:`, err)
setError(err instanceof Error ? err.message : `Failed to ${mode} row`)
} finally {
setIsSubmitting(false)
}
}
const handleDelete = async () => {
setError(null)
setIsSubmitting(true)
const idsToDelete = rowIds ?? (row ? [row.id] : [])
try {
if (idsToDelete.length === 1) {
const res = await fetch(`/api/table/${table?.id}/rows/${idsToDelete[0]}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!res.ok) {
const result: { error?: string } = await res.json()
throw new Error(result.error || 'Failed to delete row')
}
} else {
const results = await Promise.allSettled(
idsToDelete.map(async (rowId) => {
const res = await fetch(`/api/table/${table?.id}/rows/${rowId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!res.ok) {
const result: { error?: string } = await res.json().catch(() => ({}))
throw new Error(result.error || `Failed to delete row ${rowId}`)
}
return rowId
})
)
const failures = results.filter((r) => r.status === 'rejected')
if (failures.length > 0) {
const failureCount = failures.length
const totalCount = idsToDelete.length
const successCount = totalCount - failureCount
const firstError =
failures[0].status === 'rejected' ? failures[0].reason?.message || 'Unknown error' : ''
throw new Error(
`Failed to delete ${failureCount} of ${totalCount} row(s)${successCount > 0 ? ` (${successCount} deleted successfully)` : ''}. ${firstError}`
)
}
}
onSuccess()
} catch (err) {
logger.error('Failed to delete row(s):', err)
setError(err instanceof Error ? err.message : 'Failed to delete row(s)')
} finally {
setIsSubmitting(false)
}
}
const handleClose = () => {
setRowData({})
setError(null)
onClose()
}
// Delete mode UI
if (mode === 'delete') {
const deleteCount = rowIds?.length ?? (row ? 1 : 0)
const isSingleRow = deleteCount === 1
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent size='sm'>
<ModalHeader>Delete {isSingleRow ? 'Row' : `${deleteCount} Rows`}</ModalHeader>
<ModalBody>
<ErrorMessage error={error} />
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete{' '}
<span className='font-medium text-[var(--text-primary)]'>
{isSingleRow ? '1 row' : `${deleteCount} rows`}
</span>
? This will permanently remove the data.{' '}
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} disabled={isSubmitting}>
{isSubmitting ? 'Deleting...' : 'Delete'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
const isAddMode = mode === 'add'
return (
<Modal open={isOpen} onOpenChange={handleClose}>
<ModalContent className='max-w-[480px]'>
<ModalHeader>{isAddMode ? 'Add New Row' : 'Edit Row'}</ModalHeader>
<ModalBody className='max-h-[60vh] space-y-[12px] overflow-y-auto'>
<ErrorMessage error={error} />
<div className='flex flex-col gap-[8px]'>
{columns.map((column) => (
<ColumnField
key={column.name}
column={column}
value={rowData[column.name]}
onChange={(value) => setRowData((prev) => ({ ...prev, [column.name]: value }))}
/>
))}
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button
variant='tertiary'
onClick={handleFormSubmit}
disabled={isSubmitting || !hasRequiredFields}
>
{isSubmitting
? isAddMode
? 'Adding...'
: 'Updating...'
: isAddMode
? 'Add Row'
: 'Update Row'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
function ErrorMessage({ error }: { error: string | null }) {
if (!error) return null
return (
<div className='rounded-[8px] border border-[var(--status-error-border)] bg-[var(--status-error-bg)] px-[14px] py-[12px] text-[13px] text-[var(--status-error-text)]'>
{error}
</div>
)
}
interface ColumnFieldProps {
column: ColumnDefinition
value: unknown
onChange: (value: unknown) => void
}
function ColumnField({ column, value, onChange }: ColumnFieldProps) {
const renderInput = () => {
if (column.type === 'boolean') {
return (
<div className='flex items-center gap-[8px]'>
<Checkbox
id={column.name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked === true)}
/>
<Label
htmlFor={column.name}
className='font-normal text-[13px] text-[var(--text-tertiary)]'
>
{value ? 'True' : 'False'}
</Label>
</div>
)
}
if (column.type === 'json') {
return (
<Textarea
id={column.name}
value={formatValueForInput(value, column.type)}
onChange={(e) => onChange(e.target.value)}
placeholder='{"key": "value"}'
rows={3}
className='font-mono text-[12px]'
required={column.required}
/>
)
}
return (
<Input
id={column.name}
type={column.type === 'number' ? 'number' : column.type === 'date' ? 'date' : 'text'}
value={formatValueForInput(value, column.type)}
onChange={(e) => onChange(e.target.value)}
placeholder={`Enter ${column.name}`}
required={column.required}
/>
)
}
return (
<div className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'>
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{column.name}
{column.required && <span className='text-[var(--text-error)]'> *</span>}
</span>
<Badge size='sm'>{column.type}</Badge>
{column.unique && (
<Badge size='sm' variant='gray-secondary'>
unique
</Badge>
)}
</div>
</div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
<div className='flex flex-col gap-[6px]'>
<Label className='text-[13px]'>Value</Label>
{renderInput()}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,79 @@
import {
Badge,
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/emcn'
import type { ColumnDefinition } from '@/lib/table'
interface SchemaModalProps {
isOpen: boolean
onClose: () => void
columns: ColumnDefinition[]
}
export function SchemaModal({ isOpen, onClose, columns }: SchemaModalProps) {
return (
<Modal open={isOpen} onOpenChange={onClose}>
<ModalContent size='sm'>
<ModalHeader>Table Schema</ModalHeader>
<ModalBody>
<div className='max-h-[400px] overflow-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-[180px]'>Column</TableHead>
<TableHead className='w-[100px]'>Type</TableHead>
<TableHead>Constraints</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{columns.map((column) => (
<TableRow key={column.name}>
<TableCell>{column.name}</TableCell>
<TableCell>
<Badge variant='gray-secondary' size='sm'>
{column.type}
</Badge>
</TableCell>
<TableCell>
<div className='flex gap-[6px]'>
{column.required && (
<Badge variant='gray-secondary' size='sm'>
required
</Badge>
)}
{column.unique && (
<Badge variant='gray-secondary' size='sm'>
unique
</Badge>
)}
{!column.required && !column.unique && (
<span className='text-[var(--text-muted)]'></span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => onClose(false)}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
import {
ChevronLeft,
ChevronRight,
Filter,
MoreHorizontal,
Plus,
RefreshCw,
Trash2,
} from 'lucide-react'
import {
Button,
Popover,
PopoverContent,
PopoverItem,
PopoverTrigger,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui/skeleton'
interface TableToolbarProps {
tableName: string
totalCount: number
isLoading: boolean
onNavigateBack: () => void
onShowSchema: () => void
onRefresh: () => void
showFilters: boolean
onToggleFilters: () => void
onAddRecord: () => void
selectedCount: number
onDeleteSelected: () => void
onClearSelection: () => void
hasPendingChanges: boolean
onSaveChanges: () => void
onDiscardChanges: () => void
isSaving: boolean
currentPage: number
totalPages: number
onPreviousPage: () => void
onNextPage: () => void
}
export function TableToolbar({
tableName,
totalCount,
isLoading,
onNavigateBack,
onShowSchema,
onRefresh,
showFilters,
onToggleFilters,
onAddRecord,
selectedCount,
onDeleteSelected,
onClearSelection,
hasPendingChanges,
onSaveChanges,
onDiscardChanges,
isSaving,
currentPage,
totalPages,
onPreviousPage,
onNextPage,
}: TableToolbarProps) {
const hasSelection = selectedCount > 0
return (
<div className='flex h-[48px] shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-2)] px-[16px]'>
{/* Left section: Navigation and table info */}
<div className='flex items-center gap-[8px]'>
<button
onClick={onNavigateBack}
className='text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
Tables
</button>
<span className='text-[var(--text-muted)]'>/</span>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>{tableName}</span>
</div>
{/* Center section: Main actions */}
<div className='flex items-center gap-[8px]'>
{/* Pagination controls */}
<div className='flex items-center gap-[2px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={onPreviousPage}
disabled={currentPage === 0 || isLoading}
>
<ChevronLeft className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Previous page</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={onNextPage}
disabled={currentPage >= totalPages - 1 || isLoading}
>
<ChevronRight className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Next page</Tooltip.Content>
</Tooltip.Root>
</div>
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
{/* Filters toggle */}
<Button variant={showFilters ? 'secondary' : 'ghost'} size='sm' onClick={onToggleFilters}>
<Filter className='mr-[4px] h-[12px] w-[12px]' />
Filters
</Button>
<div className='mx-[4px] h-[20px] w-[1px] bg-[var(--border)]' />
{/* Pending changes actions */}
{hasPendingChanges ? (
<>
<Button variant='tertiary' size='sm' onClick={onSaveChanges} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save changes'}
</Button>
<Button variant='ghost' size='sm' onClick={onDiscardChanges} disabled={isSaving}>
Discard changes
</Button>
</>
) : (
<>
{/* Add record */}
<Button variant='default' size='sm' onClick={onAddRecord}>
<Plus className='mr-[4px] h-[12px] w-[12px]' />
Add record
</Button>
{/* Delete selected */}
{hasSelection && (
<Button variant='destructive' size='sm' onClick={onDeleteSelected}>
<Trash2 className='mr-[4px] h-[12px] w-[12px]' />
Delete {selectedCount} {selectedCount === 1 ? 'record' : 'records'}
</Button>
)}
</>
)}
{/* Clear selection */}
{hasSelection && !hasPendingChanges && (
<Button variant='ghost' size='sm' onClick={onClearSelection}>
Clear selection
</Button>
)}
</div>
{/* Right section: Row count and utilities */}
<div className='flex items-center gap-[6px]'>
{isLoading ? (
<Skeleton className='h-[16px] w-[50px]' />
) : (
<span className='text-[13px] text-[var(--text-tertiary)]'>
{totalCount} {totalCount === 1 ? 'row' : 'rows'}
</span>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button variant='ghost' size='sm' onClick={onRefresh} disabled={isLoading}>
<RefreshCw className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Refresh</Tooltip.Content>
</Tooltip.Root>
<Popover>
<PopoverTrigger asChild>
<Button variant='ghost' size='sm'>
<MoreHorizontal className='h-[14px] w-[14px]' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' className='w-[160px]'>
<PopoverItem onClick={onShowSchema}>View Schema</PopoverItem>
</PopoverContent>
</Popover>
</div>
</div>
)
}

View File

@@ -0,0 +1,331 @@
'use client'
import { useCallback, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import {
Badge,
Checkbox,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useContextMenu, useInlineEditing, useRowSelection, useTableData } from '../hooks'
import type { CellViewerData, QueryOptions } from '../lib/types'
import { EmptyRows, LoadingRows } from './body-states'
import { CellViewerModal } from './cell-viewer-modal'
import { ContextMenu } from './context-menu'
import { EditableCell } from './editable-cell'
import { EditableRow } from './editable-row'
import { FilterPanel } from './filter-panel'
import { RowModal } from './row-modal'
import { SchemaModal } from './schema-modal'
import { TableToolbar } from './table-toolbar'
export function TableViewer() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const tableId = params.tableId as string
const [queryOptions, setQueryOptions] = useState<QueryOptions>({
filter: null,
sort: null,
})
const [currentPage, setCurrentPage] = useState(0)
const [showFilters, setShowFilters] = useState(false)
const [deletingRows, setDeletingRows] = useState<string[]>([])
const [showSchemaModal, setShowSchemaModal] = useState(false)
const [cellViewer, setCellViewer] = useState<CellViewerData | null>(null)
const [copied, setCopied] = useState(false)
const { tableData, isLoadingTable, rows, totalCount, totalPages, isLoadingRows, refetchRows } =
useTableData({
workspaceId,
tableId,
queryOptions,
currentPage,
})
const columns = tableData?.schema?.columns || []
const { selectedRows, handleSelectAll, handleSelectRow, clearSelection } = useRowSelection(rows)
const { contextMenu, handleRowContextMenu, closeContextMenu } = useContextMenu()
const {
newRows,
pendingChanges,
addNewRow,
updateNewRowCell,
updateExistingRowCell,
saveChanges,
discardChanges,
hasPendingChanges,
isSaving,
} = useInlineEditing({
workspaceId,
tableId,
columns,
onSuccess: refetchRows,
})
const selectedCount = selectedRows.size
const hasSelection = selectedCount > 0
const isAllSelected = rows.length > 0 && selectedCount === rows.length
const handleNavigateBack = useCallback(() => {
router.push(`/workspace/${workspaceId}/tables`)
}, [router, workspaceId])
const handleShowSchema = useCallback(() => {
setShowSchemaModal(true)
}, [])
const handleToggleFilters = useCallback(() => {
setShowFilters((prev) => !prev)
}, [])
const handleApplyQueryOptions = useCallback(
(options: QueryOptions) => {
setQueryOptions(options)
setCurrentPage(0)
refetchRows()
},
[refetchRows]
)
const handleDeleteSelected = useCallback(() => {
setDeletingRows(Array.from(selectedRows))
}, [selectedRows])
const handleContextMenuEdit = useCallback(() => {
// For inline editing, we don't need the modal anymore
// The cell becomes editable on click
closeContextMenu()
}, [closeContextMenu])
const handleContextMenuDelete = useCallback(() => {
if (contextMenu.row) {
setDeletingRows([contextMenu.row.id])
}
closeContextMenu()
}, [contextMenu.row, closeContextMenu])
const handleCopyCellValue = useCallback(async () => {
if (cellViewer) {
let text: string
if (cellViewer.type === 'json') {
text = JSON.stringify(cellViewer.value, null, 2)
} else if (cellViewer.type === 'date') {
text = String(cellViewer.value)
} else {
text = String(cellViewer.value)
}
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}, [cellViewer])
const handleCellClick = useCallback(
(columnName: string, value: unknown, type: CellViewerData['type']) => {
setCellViewer({ columnName, value, type })
},
[]
)
const handleRemoveNewRow = useCallback(
(tempId: string) => {
discardChanges()
},
[discardChanges]
)
const handlePreviousPage = useCallback(() => {
setCurrentPage((p) => Math.max(0, p - 1))
}, [])
const handleNextPage = useCallback(() => {
setCurrentPage((p) => Math.min(totalPages - 1, p + 1))
}, [totalPages])
if (isLoadingTable) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-tertiary)]'>Loading table...</span>
</div>
)
}
if (!tableData) {
return (
<div className='flex h-full items-center justify-center'>
<span className='text-[13px] text-[var(--text-error)]'>Table not found</span>
</div>
)
}
return (
<div className='flex h-full flex-col'>
<TableToolbar
tableName={tableData.name}
totalCount={totalCount}
isLoading={isLoadingRows}
onNavigateBack={handleNavigateBack}
onShowSchema={handleShowSchema}
onRefresh={refetchRows}
showFilters={showFilters}
onToggleFilters={handleToggleFilters}
onAddRecord={addNewRow}
selectedCount={selectedCount}
onDeleteSelected={handleDeleteSelected}
onClearSelection={clearSelection}
hasPendingChanges={hasPendingChanges}
onSaveChanges={saveChanges}
onDiscardChanges={discardChanges}
isSaving={isSaving}
currentPage={currentPage}
totalPages={totalPages}
onPreviousPage={handlePreviousPage}
onNextPage={handleNextPage}
/>
<FilterPanel
columns={columns}
isVisible={showFilters}
onApply={handleApplyQueryOptions}
onClose={() => setShowFilters(false)}
isLoading={isLoadingRows}
/>
<div className='flex-1 overflow-auto'>
<Table>
<TableHeader className='sticky top-0 z-10 bg-[var(--surface-3)]'>
<TableRow>
<TableHead className='w-[40px]'>
<Checkbox size='sm' checked={isAllSelected} onCheckedChange={handleSelectAll} />
</TableHead>
{columns.map((column) => (
<TableHead key={column.name}>
<div className='flex items-center gap-[6px]'>
<span className='text-[12px]'>{column.name}</span>
<Badge variant='outline' size='sm'>
{column.type}
</Badge>
{column.required && (
<span className='text-[10px] text-[var(--text-error)]'>*</span>
)}
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{/* New rows being added */}
{newRows.map((newRow) => (
<EditableRow
key={newRow.tempId}
row={newRow}
columns={columns}
onUpdateCell={updateNewRowCell}
onRemove={handleRemoveNewRow}
/>
))}
{/* Loading state */}
{isLoadingRows ? (
<LoadingRows columns={columns} />
) : rows.length === 0 && newRows.length === 0 ? (
<EmptyRows
columnCount={columns.length}
hasFilter={!!queryOptions.filter}
onAddRow={addNewRow}
/>
) : (
/* Existing rows with inline editing */
rows.map((row) => {
const rowChanges = pendingChanges.get(row.id)
const hasChanges = !!rowChanges
return (
<TableRow
key={row.id}
className={cn(
'group hover:bg-[var(--surface-4)]',
selectedRows.has(row.id) && 'bg-[var(--surface-5)]',
hasChanges && 'bg-amber-500/10'
)}
onContextMenu={(e) => handleRowContextMenu(e, row)}
>
<TableCell>
<Checkbox
size='sm'
checked={selectedRows.has(row.id)}
onCheckedChange={() => handleSelectRow(row.id)}
/>
</TableCell>
{columns.map((column) => {
const currentValue = rowChanges?.[column.name] ?? row.data[column.name]
return (
<TableCell key={column.name}>
<EditableCell
value={currentValue}
column={column}
onChange={(value) => updateExistingRowCell(row.id, column.name, value)}
/>
</TableCell>
)
})}
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
{/* Delete confirmation modal */}
{deletingRows.length > 0 && (
<RowModal
mode='delete'
isOpen={true}
onClose={() => setDeletingRows([])}
table={tableData}
rowIds={deletingRows}
onSuccess={() => {
refetchRows()
setDeletingRows([])
clearSelection()
}}
/>
)}
<SchemaModal
isOpen={showSchemaModal}
onClose={() => setShowSchemaModal(false)}
columns={columns}
/>
<CellViewerModal
cellViewer={cellViewer}
onClose={() => setCellViewer(null)}
onCopy={handleCopyCellValue}
copied={copied}
/>
<ContextMenu
contextMenu={contextMenu}
onClose={closeContextMenu}
onEdit={handleContextMenuEdit}
onDelete={handleContextMenuDelete}
/>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export * from './use-context-menu'
export * from './use-inline-editing'
export * from './use-row-selection'
export * from './use-table-data'

View File

@@ -0,0 +1,37 @@
import { useCallback, useState } from 'react'
import type { TableRow } from '@/lib/table'
import type { ContextMenuState } from '../lib/types'
interface UseContextMenuReturn {
contextMenu: ContextMenuState
handleRowContextMenu: (e: React.MouseEvent, row: TableRow) => void
closeContextMenu: () => void
}
export function useContextMenu(): UseContextMenuReturn {
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
position: { x: 0, y: 0 },
row: null,
})
const handleRowContextMenu = useCallback((e: React.MouseEvent, row: TableRow) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
row,
})
}, [])
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, isOpen: false }))
}, [])
return {
contextMenu,
handleRowContextMenu,
closeContextMenu,
}
}

View File

@@ -0,0 +1,192 @@
'use client'
import { useCallback, useState } from 'react'
import { createLogger } from '@sim/logger'
import { nanoid } from 'nanoid'
import type { ColumnDefinition } from '@/lib/table'
const logger = createLogger('useInlineEditing')
export interface TempRow {
tempId: string
data: Record<string, unknown>
isNew: true
}
interface UseInlineEditingProps {
workspaceId: string
tableId: string
columns: ColumnDefinition[]
onSuccess: () => void
}
interface UseInlineEditingReturn {
newRows: TempRow[]
pendingChanges: Map<string, Record<string, unknown>>
addNewRow: () => void
updateNewRowCell: (tempId: string, column: string, value: unknown) => void
updateExistingRowCell: (rowId: string, column: string, value: unknown) => void
saveChanges: () => Promise<void>
discardChanges: () => void
hasPendingChanges: boolean
isSaving: boolean
error: string | null
}
function createInitialRowData(columns: ColumnDefinition[]): Record<string, unknown> {
const initial: Record<string, unknown> = {}
columns.forEach((col) => {
if (col.type === 'boolean') {
initial[col.name] = false
} else {
initial[col.name] = null
}
})
return initial
}
function cleanRowData(
columns: ColumnDefinition[],
rowData: Record<string, unknown>
): Record<string, unknown> {
const cleanData: Record<string, unknown> = {}
columns.forEach((col) => {
const value = rowData[col.name]
if (col.type === 'number') {
cleanData[col.name] = value === '' || value === null ? null : Number(value)
} else if (col.type === 'json') {
if (typeof value === 'string') {
if (value === '') {
cleanData[col.name] = null
} else {
try {
cleanData[col.name] = JSON.parse(value)
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
}
} else {
cleanData[col.name] = value
}
} else if (col.type === 'boolean') {
cleanData[col.name] = Boolean(value)
} else {
cleanData[col.name] = value || null
}
})
return cleanData
}
export function useInlineEditing({
workspaceId,
tableId,
columns,
onSuccess,
}: UseInlineEditingProps): UseInlineEditingReturn {
const [newRows, setNewRows] = useState<TempRow[]>([])
const [pendingChanges, setPendingChanges] = useState<Map<string, Record<string, unknown>>>(
new Map()
)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const hasPendingChanges = newRows.length > 0 || pendingChanges.size > 0
const addNewRow = useCallback(() => {
const newRow: TempRow = {
tempId: `temp-${nanoid()}`,
data: createInitialRowData(columns),
isNew: true,
}
setNewRows((prev) => [newRow, ...prev])
}, [columns])
const updateNewRowCell = useCallback((tempId: string, column: string, value: unknown) => {
setNewRows((prev) =>
prev.map((row) =>
row.tempId === tempId ? { ...row, data: { ...row.data, [column]: value } } : row
)
)
}, [])
const updateExistingRowCell = useCallback((rowId: string, column: string, value: unknown) => {
setPendingChanges((prev) => {
const newMap = new Map(prev)
const existing = newMap.get(rowId) || {}
newMap.set(rowId, { ...existing, [column]: value })
return newMap
})
}, [])
const saveChanges = useCallback(async () => {
setIsSaving(true)
setError(null)
try {
// Save new rows
for (const newRow of newRows) {
const cleanData = cleanRowData(columns, newRow.data)
const res = await fetch(`/api/table/${tableId}/rows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, data: cleanData }),
})
const result: { error?: string } = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Failed to add row')
}
}
// Save edited rows
for (const [rowId, changes] of pendingChanges.entries()) {
const cleanData = cleanRowData(columns, changes)
const res = await fetch(`/api/table/${tableId}/rows/${rowId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, data: cleanData }),
})
const result: { error?: string } = await res.json()
if (!res.ok) {
throw new Error(result.error || 'Failed to update row')
}
}
// Clear state and refresh
setNewRows([])
setPendingChanges(new Map())
onSuccess()
logger.info('Changes saved successfully')
} catch (err) {
logger.error('Failed to save changes:', err)
setError(err instanceof Error ? err.message : 'Failed to save changes')
} finally {
setIsSaving(false)
}
}, [newRows, pendingChanges, columns, tableId, workspaceId, onSuccess])
const discardChanges = useCallback(() => {
setNewRows([])
setPendingChanges(new Map())
setError(null)
}, [])
return {
newRows,
pendingChanges,
addNewRow,
updateNewRowCell,
updateExistingRowCell,
saveChanges,
discardChanges,
hasPendingChanges,
isSaving,
error,
}
}

Some files were not shown because too many files have changed in this diff Show More