feat--frontend-overhaul (#1537)

* reintegration

* fix: relative routes

* dynamic routes

* up/downgrade ui

* memoize

* fix breadcrumbs

* cleanup

* titles

* title format

* additional meta

* cli upgrade

* button

* lint

* Update frontend/app/src/next/components/runs/run-id.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* build errors

* unbind on return

* lint

* Fe overhaul  run form (#1547)

* simple trigger

* populated

* feat:trigger modal

* clear

* Update frontend/app/src/next/hooks/use-runs.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fe overhaul  details (#1552)

* simple trigger

* populated

* feat:trigger modal

* clear

* Update frontend/app/src/next/hooks/use-runs.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* wip activity log

* merged logs

* wip

* wip

* search box

* fudge sort

* wip improved worker sheet

* wip

* chore: improve error on dispatcher (#1538)

* fix: empty billing context (#1553)

* fix: empty

* precommit

* hotfix: priority nil pointer (#1555)

* hotfix: priority on schedule workflow (#1556)

* hotfix: priority on schedule workflow

* fix: build

* build

* lint

* build

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: abelanger5 <belanger@sas.upenn.edu>

* Fe  overhaul  burndown 1 (#1563)

* chore: improve error on dispatcher (#1538)

* fix: empty billing context (#1553)

* fix: empty

* precommit

* hotfix: priority nil pointer (#1555)

* hotfix: priority on schedule workflow (#1556)

* hotfix: priority on schedule workflow

* fix: build

* Hotfix: Handle EOF Properly (#1557)

* fix: handle EOF properly

* chore: version

* fix: debug logs

* fix: rm eof type

* hotfix: priority on cron workflow for v0 (#1558)

* fix: one more possible null deref (#1560)

* Hatchet Python Blog Post (#1526)

* feat: initial pass at first parts of blog post

* feat: initial mkdocs setup

* feat: first pass at embedding mkdocs

* fix: config

* debug: paths

* fix: unwind docs hack

* feat: start working on mkdocs theme

* fix: paths

* feat: wrap up post

* fix: proof

* fix: doc links

* fix: rm docs

* fix: lint

* fix: lint

* fix: typos + tweak

* fix: tweaks

* fix: typo

* fix: cleanup

* fix: go signature and docs (#1561)

* fix: go signature and docs

* Update examples/v1/workflows/concurrency-rr.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat: toggle doc sheet

* docs: concurrency cleanup (#1562)

* feat: storage adapter

* docs--worker-config-options (#1535)

* docs--worker-config-options

* Update frontend/docs/pages/home/workers.mdx

Co-authored-by: abelanger5 <belanger@sas.upenn.edu>

* Update worker-configuration-options.mdx

* lint

---------

Co-authored-by: abelanger5 <belanger@sas.upenn.edu>

---------

Co-authored-by: abelanger5 <belanger@sas.upenn.edu>
Co-authored-by: Matt Kaye <mrkaye97@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: bit of spacing

* single generated api

* feat: initial styling pass on runs views (#1586)

* Feat: Workflows pages (#1577)

* feat: simple workflows list page

* feat: refactor + add route for individual workflow

* feat: start wiring up workflow detail page

---------

Co-authored-by: Gabe Ruttner <gabriel.ruttner@gmail.com>

* fix: tsc

* fix: compiler

* fe  overhaul runs bash (#1599)

* wip sidebar

* wip: navigation

* fix nits

* tweaks

* clear filter button

* filter on click

* always scroll y

* wip time filters

* state changes

* wip

* wip

* functional, no style

* fix default

* wip

* queue metrics

* multi-select

* padding

* counts

* fix always num

* actions

* fix maxheight

* wip

* spacing

* fix: tenant state

* fix: tenant

* lint

* github page

* tenant alerting

* ingestors

* fix: tenant state

* build errrors

* empty state alerter

* dropdown only on mobile

* billing and limits

* fix form

* fix form

* wip

* wip

* env vars

* wip

* what a component

* wip

* fix inf render

* cleanup

* persistent tabs

* wip

* common actions

* deploy modal

* update

* delete

* you're a wizard harry

* Feat: Subrows in Runs View (#1595)

* fix: type

* feat: start wiring up subrows

* fix: tsc

* fix: hook order

* fix: hack for tsc

* fix: add some margin

* fix: empty state

* fix: empty state centering

* fix: task detail links

* fix: handle error, tweak child run styling

* fix: lint

* fix: edge case

* all mw config

* wip

* fix breadcrumb hook

* fixes

* upgrade surfaces

---------

Co-authored-by: Matt Kaye <mrkaye97@gmail.com>

* bring back error toast

* toast improvements

* lint

* fix: cloud only surface

* cloud surface

* fix breadcrumbs

* feat: config view on workflow run page (#1607)

* feat: config

* fix: rm schedule timeout

* Feat: Rework runs sidebar (#1612)

* feat: rq devtools

* fix: badge hover

* feat: improve sidebar

* refactor: hook

* fix: input undefined

* feat: waterfall diagram and cleanup runs view (#1606)

* merge

* merge

* fix merge issue

* rm debug lines

* case on count

* fix: display names

* colors and handle on click

---------

Co-authored-by: mrkaye97 <mrkaye97@gmail.com>

* fix: remove a ton of dead code (#1618)

* fix: remove n+1 query (#1619)

* Fe  overhaul managed compute and onboarding (#1614)

* fix tab state on state changes

* fix collapsed children

* setup

* layout

* layout

* move feature

* wip

* chore(deps): bump k8s.io/client-go from 0.32.3 to 0.33.0 (#1608)

Bumps [k8s.io/client-go](https://github.com/kubernetes/client-go) from 0.32.3 to 0.33.0.
- [Changelog](https://github.com/kubernetes/client-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kubernetes/client-go/compare/v0.32.3...v0.33.0)

---
updated-dependencies:
- dependency-name: k8s.io/client-go
  dependency-version: 0.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* wip

* universal install

* initial migration guide

* fakefake tokens

* fake secret

* fixes

* lint

* lint

* lint

* tidy

* chore: expose clean docs on top level

* drop reo

* wip

* static

* functional

* bump go 1.23 -> 1.24

* fix: whitespace lint

* bump golangci-lint version

* wip

* try to set up go before pre commit runs

* lint

* names

* lint

* fix: session store

* wip

* normalized snips

* fix links

* blog: mergent migration (#1611)

* universal install

* initial migration guide

* fakefake tokens

* fake secret

* fixes

* lint

* lint

* lint

* tidy

* bump go 1.23 -> 1.24

* fix: whitespace lint

* bump golangci-lint version

* try to set up go before pre commit runs

* lint

* names

* lint

* fix: session store

* fix links

---------

Co-authored-by: Alexander Belanger <alexander@hatchet.run>

* reusable state

* fix: rm unimplemented pages for now (#1615)

* feature dir

* fixes (#1616)

* with public auth

* completed typescript flow

* go blocks

* wip

* layout

* wip

* feat: read replica support and docs (#1617)

* feat: read replica support and docs

* fix: load logic

* wip

* wip

* remove provider

* migrate to static

* fix block

* lint

* fix: loadtest

* add task for linting

* cleanup

* fix meta sync

* clean examples with highlights

* get snips into app

* sync

* remove fake highlights

* always gen before build

* ignore generated

* ignore

* ignore generated

* cleanup

* always start the dev server

* examples dependabot

* app examples built off sdk examples

* auto sync on main changes

* sync the quickstarts with updating dependencies

* examples and quickstart dependabots

* only examples

* functional onboarding

* fix worker detail page

* fix detail page

* lint

* init

* tested structure copy

* prepend

* to >, ‼️ to !!

* normalize

* separate example source

* multi source

* with index

* wip

* clean generation

* migrated

* cleanup

* should build

* sync before build

* fix steps

* inline

* mkdirs

* revert build

* gen docs

* static

* rm

* update examples

* lint

* fix: ts

* fix remove lines on match

* fix: client

* add snips

* dont lint examples

* fix source

* dont test examples

* exclude quickstart

* lint

* dont break examples

* cleanup

* cleanup

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Alexander Belanger <alexander@hatchet.run>
Co-authored-by: Matt Kaye <mrkaye97@gmail.com>
Co-authored-by: abelanger5 <belanger@sas.upenn.edu>

* Merge main

* lint

* fix

* Update api-server-setup.mdx

* cleanup

* github state

* review and build

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: abelanger5 <belanger@sas.upenn.edu>
Co-authored-by: Matt Kaye <mrkaye97@gmail.com>
Co-authored-by: Alexander Belanger <alexander@hatchet.run>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Gabe Ruttner
2025-05-05 08:17:29 -07:00
committed by GitHub
parent 31bb907cd7
commit 09f8bd6118
331 changed files with 34741 additions and 497 deletions
+2
View File
@@ -31,6 +31,8 @@ node_modules
# Jetbrains IDEs
*.iml
.eslintcache
# Local docs directories
/docs/.obsidian
+6
View File
@@ -3,13 +3,19 @@ repos:
rev: v4.5.0
hooks:
- id: check-merge-conflict
exclude: ^examples/
- id: mixed-line-ending
args: ["--fix=lf"]
exclude: ^examples/
- id: end-of-file-fixer
exclude: ^examples/
- id: trailing-whitespace
exclude: ^examples/
- id: check-yaml
exclude: ^examples/
- repo: https://github.com/golangci/golangci-lint
rev: v2.1.3
hooks:
- id: golangci-lint
args: ["--config=.golangci.yml"]
exclude: ^examples/
@@ -25,9 +25,13 @@ Workflow:
items:
$ref: "#/Job"
description: The jobs of the workflow.
tenantId:
type: string
description: The tenant id of the workflow.
required:
- metadata
- name
- tenantId
type: object
WorkflowUpdateRequest:
+133 -130
View File
@@ -1621,7 +1621,10 @@ type Workflow struct {
Name string `json:"name"`
// Tags The tags of the workflow.
Tags *[]WorkflowTag `json:"tags,omitempty"`
Tags *[]WorkflowTag `json:"tags,omitempty"`
// TenantId The tenant id of the workflow.
TenantId string `json:"tenantId"`
Versions *[]WorkflowVersionMeta `json:"versions,omitempty"`
}
@@ -12963,135 +12966,135 @@ var swaggerSpec = []string{
"oEuqwcPLv8Gxmt0KyGGR+Ut5sUA8ycSNi7NYGJ1+wVzx8M5aWqnqUy6zYSQkUv8HSYGxAQ7v7cNWFscg",
"0s2/i7Njnkz9X1d/MP/91b8u+6OT4eDyyuxDyTlZG2bUP/v0x8WIh95/PT4/5u92vvc//nFx8cU6kEws",
"WA7n0WjTHNSjfnG47ey1yPbCfJ8q34s5S8hfydgiWOkXE0BO9PmPZLzS/N5tdLMVczI3gME8ApPF16r8",
"d8Bo/Aunf/sq54IRVAnCOl9jWZbbhBcd90SaUKaMeRNItO8qC3zpHjyWr0X489kJJJjhLsi7ehPaVykl",
"zYe6b83YOCIpIHDSWM5Eg/Cs0K+9sZnbk8XEVQXtLKspN5zR5dTl1fSMWK3bosGp6QWzAnBwasSh7P0F",
"xYVT8afr85OrAZOHp9fD449n1AY6Pf5cK8noIFLRtSJbNruBD+R3s/ZcKnJww4qXCXo3r4VobX10x5jk",
"C8xLThlkU0JAZKJYxWP38MkSXSGHp2RZM0Xp7EV5Fnh4DgN0h4J8Eu9vc4AxDL0HBDz+RP3vZq6wIqJF",
"6E2rCuxNl116DIs64R4dHh721l4qcbGS8rzenDtd5rUSV6hzebjCyxRQ53OP9AJVmwZhsWJvi9ZxdynA",
"D8OPTy0Gv9J6VQMXWtoha681nwc1aGDf1AuTLTmKaeEP7kphmMWi7PspSqEqU6wcF6MTqqb7o5NaPZ2P",
"Uiker78PyWm5IMU0ydgwyUiGdXSyu5Pdnex+KdltmeMXFO01cWELiGY22oDAmT3SzHJeae5sTYo2Yg9q",
"6zOVLJkpKn+zu/KnuCsY0CLTy7lMym9BVIXlMiK1UZuop6bUYotSiiovR10Zxcq0C52biwLFToxXRXFS",
"Dp5I4ktN8hvKOifxKJjCMItqkn6tvtI6Vwnf2xV5zdPV1W825qkdrSElhdqya2RHy1MUMW3TIqxOApaq",
"pg0dyaFOeMcmK7TUvDJ/zhDGrDx1CZAk0xk/CuYyfpM82j6tUt1ir8DEhN6Im4zL++bjFT9mEvdyHMI6",
"+hFC4SSlB5k7s1wwsjTny1tk4camCUXJYcOMTI7cirvBVU+LzStsbxmU8GaQvFAFny8ysMLPao17bm6Z",
"0ZdbYLfi6qE9mnnZgRUUHGi+gqoDQ7NmyyxbuMJw2RD91oMeKuEdyCJyWfuqUTSyvm50uiTI79he6OaM",
"V/DXdZ0dVCxMgys0g4klFycmKLh/skVj0G8eFlcfbtdyGk+3YC1cqrRhz7rhAoReGc7V/197JrOflSTM",
"cmcKA900swPb11VeoLQhkFeFcB4ekN+cFDF+l0IWsnRiT1g4Az8aWjy2s4htWQt5rHtGhRS17mccwjEE",
"KUyPM55PhGGUyV72c74pU0JYeekgSe4RlM0R3VX+k7xV/uCLF495XzBHX6AIPEEi1sQQAM27eceXA5YC",
"lzBPUPFXRVn+0f7h/iEjTP6I0//gv90/2j8U7zHZ0tibywg9QHFpXZ33s7yUpq1iiLGnvBB0F4HMheif",
"ie+f2bpkTDab5c3hYXXgPyCIyJRJ5fem7+cJUXMWdsb/8OdNz8cyPSOFMG8owxP+FOMHUxjc+ze0P1tr",
"CkH41LxY2gzVrXYoG6xyuQw4VpCJFxIiKbi7EzVV61avoG1c/sPRARDVovZYkv89di2JD36yn/XfnjmM",
"ESQGW/yU/Y49IAtm8aJkvJQB617BWKkAHR+B0WIKWKFECnZNIeLKDJ5Iz+R/4O+LFXdVluLr3M+9zVwu",
"Ln10fb6p7P27KrZGWRBAjO+yKHryOEpDveZaFXnPPf8dp5IgiYnIOw7m8wgFDKMHf2GuPfJ1NGirfpom",
"qShXUY6ImIGIYgGGXpJ6YxDKFwkcjLcrB8MExackHaMwhNyWzemb00kdmUmKF4WLb3r+jz1Vv405xviH",
"noEwbtghigSGTFHceF+GxPkIvwaJM3r4mHDZuRJicChOaSCTWmyRxMskzovYeDaL6JUsxLgEE+wFMcAB",
"7cSAoxjg1LI+MaAryDna48UoD36qv5k2nCfYYDQM4UNyDz0QUwuMl7EUsT9qxpKYmCNWJ1O6B2h3Fymh",
"hrfIBAnrVqm7lC1P0DmD7tcmatyGqgXp0I29EjsnyTj/rY6S1ZYXKDiIkiw80I+ydmu3koFJHifYIB6K",
"MQExKyldJOIT+lkGK9iN4PXjlgHiZbF6Hbg1BNZgtXME67e/Yuu/avc1P/bkEHvJnIdOCI2m7Td3rh78",
"ZP99rttvKqVYq/3KhjIfK9/IRknEhrAaJ+zrRoXQ6jZbpCxpUN4pJCmCD0KscWywHetkW4HENczk5M1R",
"XCPVOP3c2Cn8oEmssW1RUq2B5k+VAHvtdH/KSLij/e2i/RlcWIdbtffmFLdIdtSGppRK3BFFvgoVTsc4",
"YA5tvkvYuuNnCNMDUOQVWts2mLYeFBuubbfpXGLHtSlbbr5MjlFY3TYRgtp6thGlTajuf2GTkxiRhErz",
"g5+c458P5mkyhvbDpbyl80B+EUwSj/l1Gb6KD7ftDK+mvkwwGWbxJZvX3TdlU3pKcm1Y69UQlEhywOmJ",
"4Xd/o1rhPCEeyMg0SdH/USgSme6Ep2Pgb/4qbk4CUARDj/vtPbY93ichzwf5tpoVR4HMcASC+4Of7D8O",
"XnxvRBvKN/AVymFfRd4Yd6d9YUwr8TAQt9I7X8TJNpk2R5sB4zrOSZhP/H4zE/N0RCyrG4ii5JFOb7oR",
"KFOtFL3s9zoTixNdkWNifPATx9iJW85HutSv8kuMW7BJcTA7owjNvXVsUkJGxyhbyCgVglWscj6qZZQY",
"G9hEGi6at8lsutB55ZG4wiKt78ZezP7o2R0B9/BpUU+ABsOb9+8LQBytwgaapwn9Bww7HbZFrGk7RLK8",
"6B6YzyW1V9Uab1PiRwLGETwIwQQfqJTK1kMjZqdG1s4jU0C8MYySeKK/UVcZfsGkeqT8dnQKWLGQK1H5",
"qdldJivQ5LnyeDZexjL/zmD6lPNMCCa3KKxXc+t6b+Akd0rwvtTBx5l6V1a66xRMVMkzY6qkGjlEp5S3",
"f2zW1+0l7PnvNyX86CkUzeYRnMGYVGwD5rxQxUXl1TnA90YJwxoe/KT/abhe4hnkx0+cb8oChE7g6Grn",
"ddlsSp8CupuO9lIy+1a+Mbbs185A7/jhZP2zXul1x6gqv0synpNnS3g4Z7gKD9uNeuLC4wdRMmkyJqJk",
"4kUohjLRjYCjzPJnyeQMxbxWwatmex0RLbSmeDjVXa4VVZeiPo30z5LJ8pRP/38vf61mv4LRqqRYiV8V",
"QdkF8u/V5LQiiYfv0dxiCid3d5hpdQMoKCa/vTOmt6qfjuV+88ZPlinZ55Yzrl+t53u9wC16Zxt3qr0g",
"40wSZnk1z1pofjwm8Gb5o/ka217ApwpFpVnsiZ71Ea+cO+iieG4G+UZ/V91/LC2OyAafeBMeAyDRYJFY",
"GPELPAdAa/LrWNyRqo6HKzRZTFDkr2b2/IUyu+y8Q3GoMiBapldvq7nH52U8PFq56//AurPKArR4bU/b",
"38rWt7yQ3M4cPIdZLJmv/dlTZ/jOkbM9h0C2NzMlU910hLtymCcoJo4qYobijEBv/KT+SiG4D5PHWGmN",
"FhrjMySXdPJd1xdMNoM7AtNiNXORWrBcL+Ro75D+7+rw8AP73/9YBJLMTEgHXpEsZ5CO4V2SwhKoCYVv",
"CWBl4sCPbPD24K5fNhZIbQHpyPikk49bKh+Lu7NyKYkPApY9y34fzrNrKae7Sd7xJjsTlbf6R6HfjjgK",
"mKnS8AqUX2QmXiCR5vTks4al+UBRd13VHckNgqTEvisXHymcR+Cp7pkpK7pbJz54k1ctPjgK2oiPVCJt",
"WfHBB+qkRyc9DNKjxL0rlB7SlbKXZnHTnV2hNkvhALZvECnlBPG7evr6le4wetV6NjKqh78EgZj74CJ6",
"2LROLduafW8O8QuWFLsW9yRIIwQx4UWHXcBbo680AqQNKKtylB6r1Pf5c737vQdvDlDqtGV57vxb7cGZ",
"YfcW8oVqPly8U05cUdxUup7rccgbr9Zn27Mm70w8FAdRFrKoR0yVchJHT/rvKhDPJJDi6OlWNrAzQjUR",
"aIOruxCV6YCzX8HrLcKt2sZedEbctl2JFgwYzY6SporHKoas0KA6EGVV9ig3NJlXoi0dlmWqZs8y7TZX",
"vcl1mtdzwTttfmnyplIdUlSp5reWAn0CdXa9o0mhFwkXX6+8MpNAJ7o60dVWdImM0I2Pyz3gxfCxAGC9",
"aDph906v2s8kUKchpcHfpGOXea0lDjeXqLAoW2TJngaZIu4YK1K7i0jVXMMMR2UGWgGDF/n558PRnv5L",
"01OMAsmBOPSQniuGJErhJjHb3v/1Q0YU/+t7czCB9TLA8RlHAQZ+4JiwngZpUFrezkZ6L8BlnebeoWdS",
"jgzdqxD0AizuHo+eP+7kZwx3da4il53PGb+0R9UitowesV9TgLULZe9kVye7amQXQTMUT3BjukbRrrX0",
"+gzJlZhiZ+2Rnrls0pxM+Q01DzXzZB1H2wt62mHr3sTwzekkyc5Lkjr+XLV4gXMhU+SfzwcgDaboATZZ",
"QaKVAJN2N4oQUVWZpSyUAzuIDzmePVGTgLe7497Od3pi38Wed0/1diKyR3FdKbqnKqQK7K8xv8p4RX8a",
"ZrVJ9RULN8uk1u+EXeQRP4p10uj1SKPu2fCvKIs0xl+/JFogYYcEqhr61zJnRyeGXjbwL4IPMHKKIeMt",
"CzPXJvQWdEB7fUIwCq3voSFVvB6bTYOj5jE069AWkBHvZYy5AoROzCqT2tfPPn984mtpOfmF3teCBz59",
"iFIYiFSbNVCcas0WgSTvv14l1SWueeHENWY1IC7/a96bsVtJLG73LSFHvFraiX4ZverLcj44n8itst/L",
"XI+LwnFtLsQFUrsyfaWbcC0NSX1RPhNFq3AWRtp1xTlZZZ8fCBMUT+oJfHfSDW+g2qYbE+ZVul+0rmbH",
"jysrm9miSGYtX5pLSNeXKAJ58llLCU/cVE53V4JgbzZda3YBz4F9EzreKd561FCrOzP1Wpho7etMv/pw",
"TN3CXF0paWcT9OiFS0lXNWBXStrVRl2qlLSbljzAkJCmEAjMdk928WSX+vciGrmgeDISfVwTbL8ONakh",
"Zgkdqe9Jx0rF5Do2NK2Mj1Q99vqLNlUeHbuVX+/sSVWykeEDD8UsLflExnF0vr6y8ahquON2hd2bDEYP",
"xDm1uxF7ZyMyBEha18zCdbowypN2/LXiJzc5M7VksDqF4xDVwWspFRM0W/KotMv/3uVPebFr1Hv45HSJ",
"Stu1z5vCyOALfHLJa5HDpMIsB6fYNcEFlxWtAZShm4PTBUFMs3j5HDQuEA6zmOefEY6vF7mSZvv5MhfS",
"bOotuI7W4dAvo2uIJU99A5+8BxBl0JwAR2WY/ZOy29EH1vTI79F/veH/ekPFe32inK+rzZOTL4OXf1Sp",
"curpnDUebCZFzjrPCgu9COqiAGJ7bJhmtDDkLu9CZuNabJDuCMAQwHDR4Bbm/P0yYQicEtr4fHm12Vcf",
"BfrmvzYz61DwpzBP4Y8AwrCaLpYfUGQlYGc+bz6YHIyz6N4e9vMxi+4FeeBcJuBaoUD7vGLBQJffUjjg",
"l5QOuL146KLEt0w+MDbVhQResZRwS0fPHRlaarKCiWuTGjys5NVnq+cIcDcoxIGhXcZpZ0jzgC36r8f8",
"sEzPHmvMyil/SMZ/wcDBcmFIg3kuhU5Iba2QElmr1yKfmBvN0cfKfXMOftYv8Km71sudjQud1hmyuxO7",
"6cTuCd/vKvnAre4DbqeaX30lCI6AbVHNq3GrFQpMdArz1ShMFD8gAtsGWMte5qCxAfva6UoZK6bhY6Eo",
"MYntLjbMFD6d0+KaYqb5BLW03rm/tShpjhK34GiO2xeNiObgLhIILQijY0tz9LPim9WEago+lz/s8X8/",
"cyaOIIFVdj5lv2N1sHNhZd5nd6vBFviqHrY9hY5d162N3MspZJu5t8BInAhzcrVW4i/sI9NrdW9a23HC",
"7rxr3RVOWO/T28X07os9vnXkXA7fznCueBTbmnPrNN8MzsaM+Vqd0WQvM4t/ZV+7M5qkRg0fC53RJLY7",
"Y9B0RstpcTW2oBjv4Cf/w8EI9IAAwrtLk1nTszdODb+GKSiWbYONf94o775bC+8uYgO+Dq7doix355ak",
"dopJCxuzMnnx7wxmcE/W52/KFs5ay2r+6ha5VmB8huSftNdXMcUuyoydehmwS8He67deCrS32Asw7wGm",
"GCWxpPtOJr60TKTiSO3OTAmWcjr0RWViCgjcYxdOLqEStDW/nmqKlRgCAs9ow+5d2jZXoVnFGyaHcsXr",
"e6mk6GwLXiuVYdlU+swir7UIxtHYuYvGKZ1Zddzk4pai2jvjvy4qcUWPvXkSoeCpOWWL7ODxDi4JW2Qo",
"wSXr0aVrOTChZTEXT2k3OlfPxrMe4QgE9/WJWka0ifcIx9Mkua86P9nn7/xr5/zkOVp0nLQ5PZRQvU3s",
"sKHKRtcxyMg0SdH/wZBP/H4zE3+FZJqErEQAiKLk0VxViW8QswM5C+j6jH1cihEPMAEpsbLjiH7leuzi",
"OCNTjx1Wygx5jWHK70wYQBcUoaznLnLm28M3Bjzo3MNQJtRKAStTCEJxxxMlnGCKtFKem1EFhkGWIvLE",
"8BMkyT2CdFCW/PhGpweG0uKMkhDoDixMB015s0bnozIBlgRyjDs5LOTw+Wigo6qFJC5juZPFWyeLq4yg",
"JPH5aIl0XaWBTQzWRScyBBT5qzZL1+potjipc5RheVc7ht4ihrZyniNH12pUUY9jbxNXVqJE2K7dXK3f",
"XWBCTDufgapbVdiZ7lJlGy5V1N5UL1WW9E8YqqfVsm5eKM0bP3GGMpZu3BE/Xm9bK7htoM7igvKhkwhb",
"V2BRFxErKaroJCcac2ocEwJnc5EchrV1qPm6a8k0OglSF8CGMAvvFyKEE0G0fQeEF77Ea2KUTTF0CmnH",
"mrf3LEmJKw+z5h0Lb2M2gDSLxVY1PL5A8Txj8RD8cte03OetsFS6XAA18oVt+EsIlHxNtb4A3syxKPxn",
"SEZ82E60vJx10C7LlcXTIIbrDhTbfKCQu7QWqSHu4vcek/S+7sFYHtZpDZToYiTyEHWOiu8MqRQhdbU2",
"KDJUGD3v6Mnt6Jz423Yrp5H/4qlCxCA2Fnr1t28F/uHY2FCJHMPMYatEH3JrO87dvus3nfEWcdZzqVzv",
"nqcakgvv+tjbXDe8emWZY6KrRLX0UVM+ASq+neY4XvSSSiKaHy/bZ4jUa/IYEkVqhXS6dJFaukgNL7jB",
"TVSoevRyySNNcDsXmdM8SAWC6Y6nW5lUsrhH1UeG9QfUNgLnp/7PptvxAic0amBBprt8WV5ifTNoOgZ3",
"2EwQ27Xoe+Xu8tz+Wrjol25+Kdwr0tTi/HzArjgaXdT8IoQztA70fgNfD9joHXO/PHPnuREutdIQHMZl",
"vNlFHLHt7hzaG3Jof9dxH7tkJcg3qa3JsDqJg6dgDtdkR4zY2J282Rljgm9YZ1H8QhaFioh3KJ1dqJod",
"RerWDRtsjTrWZ8+x+AV5X6bb72TAygE8A5h4g1OWtHIKvQjIHbQlPwGYDEJr9pO3b0zZTzYQudemzIYu",
"ebrYmi29sV9Alrhf57vJQux0M8Faulk0rzIdUwjvQBYR/8NhryAqNpGYSc39fpHJefl3b/zksQnMk4pP",
"9lfimzC7usue1dtbq0z0psZ0LNvpAW8MSDCtXPbUWUyvvl6nfk/CkeEaDCxi1KtXJa+6iGfU3R41JF3i",
"ZLOJmxt8EKRJ3GyR0FbeX8k4B4qkaDJpDJ84SZP4VZspO5M1Um0sCum0E0iUSbzfkBzYdnBbw1mXztwW",
"vPMmU8o4JaP4NtPRDu2n2s28xzWZOMdP3p3I9rmyhKC6FMHuSUHHT+vLC6oZBRvODFpAxhIWeqd2DVZ6",
"Rc+tyVynSvfgJ/3PnvzVrdRFVRE7X3xQwtnxwhdq9TawChjdfOkLxxoVxk3sso6Wa0aY0dTurqJIEDfP",
"vbrLxCWZa5fDk7aYs9akOju1uQuO/VbKegXywU1/Mxpw9eLrVwvNsQndKXmbT8my7L/roZC13+D5eBsP",
"73OQUqRZ7qtLYPHG33UP5obgM7w2N8ImbobXC9ex8VGGhwkgGYZOpZtk20WOtCPWVxwuXYC7R3HoBBVr",
"2BqkLygOm6HZeQ8KQTPogTsKaCVi8hFg+YBRX4L/5vDN0d4h/d/V4eEH9r//sXqoWPdjOoGZeENA4B6F",
"wnetREghHsO7JIXrBPkjm2GVMNdg+Q7FCE8Xh1n23yieVwX0SjG9Po9g1f32av2BZduxO9asJUZyPY5A",
"FhbpkgoYeAI0quiK7K/nBnaMft7lYpadGd6Z4Zs3wzvbsrMtX+TdA16y+CsTQF2S8mb9voZCrLmep6CG",
"WUTVY4PXULVcxH84kp07L+I2exHXdy5SBLBT4RKdMdUZUztjTOXLyEX1SnyzTlX1FYMrL+2Gy9JXJUzn",
"dVitVWKxANZrlxz8VH/uVfK4NEYlmUFuabPseGySAQfWvMVGVG9tuJJ5d7t4pXK8kgVP7QISLLTRELm0",
"Egbc6VpEO8V961THnSre9bim9coRN8NApWp4zl8I1VYrBV4MH+3vhNyfCV3xDruTXLn5xUp9boZa0DZa",
"R9WwDW3qnlg3f6PJLdsFeeo5oe3wd2Jx88Udty6hphB0dVS+nieamiwu+JHN8lhaBEIiu9uDFVNimMWd",
"FN6kFJY7oG1AG/lrtRs2WIiqvTmqS+BXedLsxK+T+BUGSZNNvHKRy7O07wVJFpOGEB3WRua8kuUFwANA",
"ERhHkElfTdyYT+OfIeFZ4PEJm3HnRW9TarIdT01Y2KwFj96cVDj5dN5wyx19AUmLJSwssn+GYYoPgixN",
"YT1nY3464A092q3CvdcYpp8hORGDrZHu6Ewt6YxB3BW6eflCNzDIUkSemBgPkuQeweOMyq4/b6ioKj1u",
"K5KbJHe2/QYyniAyzcYHAYiiMQjureR8kszmESSQ0/QFnd8z6iM6ES/z8ZkNfUFxeSKHLxH428M3DfcJ",
"gZg3rM47hSAUNe2ihG+GsYaiEuvPJWQWcCcXWJzDEX2YgNQuCkb062KIY13bY43Bs36cMehaIixJJhFc",
"D72xoX9xeuPoWzG95Yj75egNxQ+IQJfCl9Ia5h2Y0e2kvukIV6zvQMy1Ri2uT+QUPxEhLDemuMDOXnRW",
"qyz3awl7OeVdGU6IBdo7AEEA58TueTtm37HysIlJKtSmbz7v46/Hn8QH5xM1F2asoT6+chP9dVEAirw4",
"tit7705fKWRZFGsqttHv7eiL9/HXVf+MDr4C+uIr7+iroTo9RdIC9BUlExTbyeosmWAPxR5gunG/xsA4",
"YwOth5aYCqbjb6iCrNM5OkomExh6KO6Oz1t1fC6qdUo1rufkKJkkGWlghiQjbtyQZC/v6xE0mmxZPaWO",
"SBuMUUY9rmQ7g7MxTPEUzVscgbRObscgrkK+5t3EM6K1Erh50vbnIR1F3ZlokTORjsFmkpwDjB+TtCYS",
"gYtJIUk92b5OpF7KMddnY5xMQTxRE22TsREwyEKFqE6c75A452RVpHQHJkrhhAqytO7Qx1vgWotExems",
"i20kGNvEMBJ53TXXTtjpkoRcbR4cgeB+LTcMIzryFl8wNIialjcODzDFAoTa0r2inYxfwTB9MNiIg/gu",
"+QzJNzHoSguXaJDmGR2O9g/3D005I7SwkT9V1xuHmiRXNYsthcrVkPN36KWQZGlcQF7JzqZSKotjFE/y",
"KX7sySH3kjl/oprPJjftEY6nSXK/J6KIDn6KHxze41FNIVpXo4z47+5P7cRA9igeNdGGg3gc365J+Dq9",
"8PJ6ofxeTidTa+iOaHHjxBwHAs8uh2TZVBb9q+cYYfdg18QaW8s3qwl+49Dz2DeBGoqZoZjQJnVV3lCB",
"HbVdHXtuEXsyn0Bli9ryqOJN9sezQx1vg7XBKczxYaqIEKwLODXo+N0JN20d+CdW3HnDKhGlldc61Giu",
"DyBlZjWlQhJMa3xdtYTMW+0MLa/BlcAQUNAbNl0hMJBJlG3uEYsjr3HIOk4zc5pgiGWYraRNyi8znDKT",
"qPBxp1QILc5FW/m8oU1WDwVg97pq86+rTMchjWIWfNzQa7Kw3Dmhhcn1Gl75LPiyp+Otl+Yt/QnRMozl",
"Yva5c1c7O3ArGGx9dbU5MlwfOnOrq8hlmzYOnSRC2Tzs5IHVQFyOORvMRKf0+nSTinn0FeM9qJsOq6Zs",
"kU5/G/jZkNKSJ6RcQb2hxasNmQGbpEk2Z3lCcxDkRllBYZ2+wCe/MYfDmoXEkrm75aVSl757C62JhfKF",
"txJcMq+MNTZEpkRom+lloQQvWym5rgzssu8N7ph3G2eUOmDYY1wVAQIxUTyFsHcHSTCFoS2bdC74t9yQ",
"EmSwYNaYF8sVo8HbKklMlxqmSw2zhtQwrUSzkA3Y4VaroMmdxLKIrdkhF8yvIJfXLOVkwNRypmAn77bK",
"BMxJcVETsBz4N4YghakK/OsZQwFZJBmXB1ka+R98//nm+f8FAAD//zEI5IvvdQIA",
"d8Bo/Ltmw3VZgbhAaF8xXTCVKmdY57c06IW6jLdyihNpmZkS8U0g0b6r5PKl6/VYPkLhr3InkGCGkyDv",
"6k1oX6XrNNfsvjUR5IikgMBJY5UUDcKzQr/2NmxuphbzYRWUvizS3HD0l1OXV9MzYrVuiwanpofRCsDB",
"qRGHsvcXFBcO25+uz0+uBkzMnl4Pjz+eUdPq9PhzrYCkg0j92YqC2ewG9pLfzUp5qYDEDetzpj/cnCGi",
"tfUtH2OSLzCvZGUQPAkBkYliFY/dwydL0IYcnpJlzRSlIx3lWeDhOQzQHQrySby/zQHGMPQeEPD4y/e/",
"m7nCiogWET2tCrs33aHpoTHq4Hx0eHjYW3sFxsUq1fMydu50mZdgXKEq51EQL1OXnc890utebRqExWrI",
"LVoe3qWuPww/PrUY/ErrVY2HaGmSrL2EfR4roYF9Uy9MtuSEp0VVuCuFYRaLavKnKIWq+rHyh4xOqJru",
"j05q9XQ+SqUmvf7sJKflghTTJGPDJCMZLdLJ7k52d7L7pWS3ZY5fULTXhJstIJrZaAMCZ/YANst5pbmz",
"NdfaiL3TrU+AsmQCqvwp8Mpf+K5gQItML6dIKT8xUYWby4jURm2inpoKji0qNKp0H3XVGSvTLnRuLgoU",
"OzFeFcVJOSYjiS81yW+oFp3Eo2AKwyyqySW2+gLuXCV8b1c7Ns+CV7/ZmGeMtEaqFErWrpEdLS9cxLRN",
"i7A6CVgGnDZ0JIc64R2brNBS88r8OUMYk/3U5VWSTGf8KJjL+E3yaPtsTXWLvQITE3ojbjIu7/KPV/xG",
"Srh1OYR19COEwklKDzJ3ZrlgZGnOl7fIwo1NE4pKxoYZmRy5FVeOq54Wm1fY3jIo4c0geaGKaV9kYIWf",
"1Rr33Nwyoy+3wG7FLUR7NPNqBiuoY9B8s1UHhmbNllm2cIXhsiH6rQc9VMI7kEXksvaxpGhkfTTpdEmQ",
"X9290IVckoY8qs4BVCxMgys0g4klxScmKLh/sgV50G8eFlcfbrd9Gk+3YC1cKuBhT+bhAoRecM7V/197",
"JrOflSTMcmcKA900swPb11VeoLQhkFeFcB51kN+cFDF+l0IWCXViz4M4Az8aWjy2s4htyRB5CH1GhRS1",
"7mccwjEEKUyPM56mhGGUyV72c74pU0JY1eogSe4RlM0R3VX+k7xg/uCLh5R5XzBHX6CIZ0EihMUQV827",
"eceXA5ZZlzBPUPFXRVn+0f7h/iEjTP421P/gv90/2j8UzzzZ0thTzgg9QHFpXZ33s7yUpq1iiLGnvBB0",
"F4FMseifie+f2bpkqDeb5c3hYXXgPyCIyJRJ5fem7+cJUXMWdsb/8OdNz8cy6yOFMG8oox7+FOMHUxjc",
"+ze0P1trCkH41LxY2gzVrXYoG6xyuQw4VueJ1yciKbi7E6Va61avoG1c/sPRARBFqPZY7YA9di2JD36y",
"n/XfnjmMESQGW/yU/Y49IAM6eK0zXiGBda9grFTXjo/AaDEFrP4iBbsmZKQygyeyPvkf+LNlxV2Vpfg6",
"93NvM5eLSx9dn28qe/+uiq1RFgQQ47ssip48jtJQL+VWRd5zz3/HqSRIYiLSmYP5PEIBw+jBX5hrj3wd",
"Ddqqn6ZJKqpglCMiZiCiWIChl6TeGITyoQMH4+3KwTBB8SlJxygMIbdlc/rmdFJHZpLiRT3km57/Y0+V",
"hWOOMf6hZyCMG3aIIoEhARU33pchcT7Cr0HijB4+Jlx2roQYHGpeGsikFlsk8TKJ8yI2ns0ieiULMS7B",
"BHtBDHBAOzHgKAY4taxPDOgKco72eI3Lg5/qb6YN5wk2GA1D+JDcQw/E1ALj1TFF7I+asSQm5oiV35Tu",
"AdrdRUqo4S0yQcK6VeouZcsTdM6g+7WJGrehakE6dGOvxM5JMs5/q6NkteUFCg6iJAsP9KOs3dqtJHaS",
"xwk2iIdiTEDMKlUXifiEfpbBCnYjeP24ZYB4WaweHW4NgTVY7RzB+u2v2Pqv2n3Njz05xF4y56ETQqNp",
"+82dqwc/2X+f6/abSinWar+yoczHyjeyURKxIazGCfu6USG0us0WmVAalHcKSYrggxBrHBtsxzrZViBx",
"DTM5eXMU10g1Tj83dgo/aBJrbFuUVGug+VMlwF473Z8yEu5of7tofwYX1uFW7b05xS1yKLWhKaUSd0SR",
"r0KF0zEOmEOb7xK27vgZwvQAFHmF1rYNpq0HxYZr2206l9hxbcqWmy9zbhRWt02EoLaebURpE6r7X9jk",
"JEYkodL84Cfn+OeDeZqMof1wKW/pPJBfBJPEY35dhq/ie3A7w6upLxNMhll8yeZ1903ZlJ6SXBvWejUE",
"JXIncHpi+N3fqFY4T4gHMjJNUvR/FIpEZlHhWR74m7+Km5MAFMHQ4357j22P90nI80G+rWbFUSAzHIHg",
"/uAn+4+DF98b0YbyaX2FcthXkY7G3WlfGNNKPAzErfTOF3GyTabN0WbAuI5zEuYTv9/MxDzLEUsWB6Io",
"eaTTm24EylQrRS/7vc7E4kRX5JgYH/zEMXbilvORLvWr/BLjFmxSHMzOKEJzbx2blJDRMcoWMkqFYBWr",
"nI9qGSXGBjaRhovmbTKbLnReeSSusEjru7EXsz96dkfAPXxa1BOgwfDm/fsCEEersIHmaUL/AcNOh20R",
"a9oOkSzdugfmc0ntVbXG25T4kYBxBA9CMMEHKlOz9dCI2amRtfPIFBBvDKMknuhv1FXiYDCpHim/HZ0C",
"VoPkShSUanaXycI2eboPnuSXscy/M5g+5TwTgsktCuvV3LreGzjJnRK8L3XwcabelVUEOwUTVUnNmIGp",
"Rg7RKeXtH5v1dXsJe/77TQk/egpFs3kEZzAmFduAOS9UzVJ5dQ7wvVHCsIYHP+l/Gq6XeGL68RPnm7IA",
"oRM4utp5uTeb0qeA7qajvZQjv5VvjC37tTPQO344Wf+sV3o5M6rK75KM5+TZEh7OGa7Cw3ajnrjw+EGU",
"TJqMiSiZeBGKoUx0I+Aos/xZMjlDMS+B8KrZXkdEC60pHk51l2tF1aWoTyP9s2SyPOXT/9/LX6vZr2C0",
"4itW4le1VXaB/Hs1Oa1I4uF7NLeYwsndHWZa3QAKislv74zpreqnY7nfvPGTZUr2ueWM61fr+V4vcIve",
"2cadai/IOJOEWV7NsxaaH48JvFn+aL7GthfwqfpTaRZ7omd9xCvnDroonptBvtHfVfcfS4sjkswn3oTH",
"AEg0WCQWRvwCzwHQmvw6FnekKg/iCk0WExT5q5k9f6HMLjvvUByqDIiW6dXbau7xeRkPj1ZF+z+w7qyy",
"AC1e29P2t7L1La9PtzMHz2EWS+Zrf/bUGb5z5GzPIZDtzUzJVDcd4a4c5gmKiaOKmKE4I9AbP6m/Ugju",
"w+QxVlqjhcb4DMklnXzX9QWTzeCOwLRYJF2kFiyXITnaO6T/uzo8/MD+9z8WgSQzE9KBVyTLGaRjeJek",
"sARqQuFbAliZOPAjG7w9uOuXjQVSW0A6Mj7p5OOWysfi7qxcSuKDgGXPst+H8+xayulukne8yc5E5a3+",
"Uei3I44CZqo0vALlF5mJF0ikOT35rGFpPlDUXVd1R3KDICmx78rFRwrnEXiqe2bKavnWiQ/e5FWLD46C",
"NuIjlUhbVnzwgTrp0UkPg/Qoce8KpYd0peylWdx0Z1eozVI4gO0bREo5Qfyunr5+pTuMXrWejYzq4S9B",
"IOY+uIgeNq1Ty7Zm35tD/IIlxa7FPQnSCEFMeC1jF/DW6CuNAGkDyqocpccq9X3+XO9+78GbA5Q6bVme",
"O/9We3Bm2L2FfKGaDxfvlBNX1EyVrud6HPLGq/XZ9qzJOxMPxUGUhSzqEVOlnMTRk/67CsQzCaQ4erqV",
"DeyMUE0E2uDqLkRlOuDsV/B6i3CrtrEXnRG3bVeiBQNGs6OkqeKxiiErNKgORFmVPcoNTeaVaEuHZZmq",
"2bNMu81Vb3Kd5vVc8E6bX5q8qRSdFMWv+a2lQJ9AnV3vaFLoRcLF1yuvzCTQia5OdLUVXSIjdOPjcg94",
"MXwsAFgvmk7YvdOr9jMJ1GlIafA36dhlXmuJw80lKizKFlmyp0GmiDvGitTuIlI11zDDUZmBVsDgRX7+",
"+XC0p//S9BSjQHIgDj2k54ohiVK4Scy293/9kBHF//reHExgvQxwfMZRgIEfOCasp0EalJa3s5HeC3BZ",
"p7l36JmUI0P3KgS9AIu7x6Pnjzv5GcNdnavIZedzxi/tUbWILaNH7NcUYO1C2TvZ1cmuGtlF0AzFE9yY",
"rlG0ay29PkNyJabYWXukZy6bNCdTfkPNQ808WcfR9oKedti6NzF8czpJsvOSpI4/Vy1e4FzIFPnn8wFI",
"gyl6gE1WkGglwKTdjSJEVFVmKQvlwA7iQ45nT9Qk4O3uuLfznZ7Yd7Hn3VO9nYjsUVxXiu6pCqkC+2vM",
"rzJe0Z+GWW1SfcXCzTKp9TthF3nEj2KdNHo90qh7NvwryiKN8dcviRZI2CGBqob+tczZ0Ymhlw38i+AD",
"jJxiyHjLwsy1Cb0FHdBenxCMQut7aEgVr8dm0+CoeQzNOrQFZMR7GWOuAKETs8qk9vWzzx+f+FpaTn6h",
"97XggU8fohQGItVmDRSnWrNFIMn7r1dJdYlrXjhxjVkNiMv/mvdm7FYSi9t9S8gRr5Z2ol9Gr/qynA/O",
"J3Kr7Pcy1+OicFybC3GB1K5MX+kmXEtDUl+Uz0TRKpyFkXZdcU5W2ecHwgTFk3oC3510wxuotunGhHmV",
"7hetq9nx48rKZrYoklnLl+YS0vUlikCefNZSwhM3ldPdlSDYm03Xml3Ac2DfhI53irceNdTqzky9FiZa",
"+zrTrz4cU7cwV1dK2tkEPXrhUtJVDdiVkna1UZcqJe2mJQ8wJKQpBAKz3ZNdPNml/r2IRi4onoxEH9cE",
"269DTWqIWUJH6nvSsVIxuY4NTSvjI1WPvf6iTZVHx27l1zt7UpVsZPjAQzFLSz6RcRydr69sPKoa7rhd",
"Yfcmg9EDcU7tbsTe2YgMAZLWNbNwnS6M8qQdf634yU3OTC0ZrE7hOER18FpKxQTNljwq7fK/d/lTXuwa",
"9R4+OV2i0nbt86YwMvgCn1zyWuQwqTDLwSl2TXDBZUVrAGXo5uB0QRDTLF4+B40LhMMs5vlnhOPrRa6k",
"2X6+zIU0m3oLrqN1OPTL6BpiyVPfwCfvAUQZNCfAURlm/6TsdvSBNT3ye/Rfb/i/3lDxXp8o5+tq8+Tk",
"y+DlH1WqnHo6Z40Hm0mRs86zwkIvgroogNgeG6YZLQy5y7uQ2bgWG6Q7AjAEMFw0uIU5f79MGAKnhDY+",
"X15t9tVHgb75r83MOhT8KcxT+COAMKymi+UHFFkJ2JnPmw8mB+MsureH/XzMontBHjiXCbhWKNA+r1gw",
"0OW3FA74JaUDbi8euijxLZMPjE11IYFXLCXc0tFzR4aWmqxg4tqkBg8refXZ6jkC3A0KcWBol3HaGdI8",
"YIv+6zE/LNOzxxqzcsofkvFfMHCwXBjSYJ5LoRNSWyukRNbqtcgn5kZz9LFy35yDn/ULfOqu9XJn40Kn",
"dYbs7sRuOrF7wve7Sj5wq/uA26nmV18JgiNgW1TzatxqhQITncJ8NQoTxQ+IwLYB1rKXOWhswL52ulLG",
"imn4WChKTGK7iw0zhU/ntLimmGk+QS2td+5vLUqao8QtOJrj9kUjojm4iwRCC8Lo2NIc/az4ZjWhmoLP",
"5Q97/N/PnIkjSGCVnU/Z71gd7FxYmffZ3WqwBb6qh21PoWPXdWsj93IK2WbuLTASJ8KcXK2V+Av7yPRa",
"3ZvWdpywO+9ad4UT1vv0djG9+2KPbx05l8O3M5wrHsW25tw6zTeDszFjvlZnNNnLzOJf2dfujCapUcPH",
"Qmc0ie3OGDSd0XJaXI0tKMY7+Mn/cDACPSCA8O7SZNb07I1Tw69hCopl22DjnzfKu+/WwruL2ICvg2u3",
"KMvduSWpnWLSwsasTF78O4MZ3JP1+ZuyhbPWspq/ukWuFRifIfkn7fVVTLGLMmOnXgbsUrD3+q2XAu0t",
"9gLMe4ApRkks6b6TiS8tE6k4UrszU4KlnA59UZmYAgL32IWTS6gEbc2vp5piJYaAwDPasHuXts1VaFbx",
"hsmhXPH6XiopOtuC10plWDaVPrPIay2CcTR27qJxSmdWHTe5uKWo9s74r4tKXNFjb55EKHhqTtkiO3i8",
"g0vCFhlKcMl6dOlaDkxoWczFU9qNztWz8axHOALBfX2ilhFt4j3C8TRJ7qvOT/b5O//aOT95jhYdJ21O",
"DyVUbxM7bKiy0XUMMjJNUvR/MOQTv9/MxF8hmSYhKxEAoih5NFdV4hvE7EDOAro+Yx+XYsQDTEBKrOw4",
"ol+5Hrs4zsjUY4eVMkNeY5jyOxMG0AVFKOu5i5z59vCNAQ869zCUCbVSwMoUglDc8UQJJ5girZTnZlSB",
"YZCliDwx/ARJco8gHZQlP77R6YGhtDijJAS6AwvTQVPerNH5qEyAJYEc404OCzl8PhroqGohictY7mTx",
"1sniKiMoSXw+WiJdV2lgE4N10YkMAUX+qs3StTqaLU7qHGVY3tWOobeIoa2c58jRtRpV1OPY28SVlSgR",
"tms3V+t3F5gQ085noOpWFXamu1TZhksVtTfVS5Ul/ROG6mm1rJsXSvPGT5yhjKUbd8SP19vWCm4bqLO4",
"oHzoJMLWFVjURcRKiio6yYnGnBrHhMDZXCSHYW0dar7uWjKNToLUBbAhzML7hQjhRBBt3wHhhS/xmhhl",
"UwydQtqx5u09S1LiysOsecfC25gNIM1isVUNjy9QPM9YPAS/3DUt93krLJUuF0CNfGEb/hICJV9TrS+A",
"N3MsCv8ZkhEfthMtL2cdtMtyZfE0iOG6A8U2HyjkLq1Faoi7+L3HJL2vezCWh3VaAyW6GIk8RJ2j4jtD",
"KkVIXa0NigwVRs87enI7Oif+tt3KaeS/eKoQMYiNhV797VuBfzg2NlQixzBz2CrRh9zajnO37/pNZ7xF",
"nPVcKte756mG5MK7PvY21w2vXlnmmOgqUS191JRPgIpvpzmOF72kkojmx8v2GSL1mjyGRJFaIZ0uXaSW",
"LlLDC25wExWqHr1c8kgT3M5F5jQPUoFguuPpViaVLO5R9ZFh/QG1jcD5qf+z6Xa8wAmNGliQ6S5flpdY",
"3wyajsEdNhPEdi36Xrm7PLe/Fi76pZtfCveKNLU4Px+wK45GFzW/COEMrQO938DXAzZ6x9wvz9x5boRL",
"rTQEh3EZb3YRR2y7O4f2hhza33Xcxy5ZCfJNamsyrE7i4CmYwzXZESM2didvdsaY4BvWWRS/kEWhIuId",
"SmcXqmZHkbp1wwZbo4712XMsfkHel+n2OxmwcgDPACbe4JQlrZxCLwJyB23JTwAmg9Ca/eTtG1P2kw1E",
"7rUps6FLni62Zktv7BeQJe7X+W6yEDvdTLCWbhbNq0zHFMI7kEXE/3DYK4iKTSRmUnO/X2RyXv7dGz95",
"bALzpOKT/ZX4Jsyu7rJn9fbWKhO9qTEdy3Z6wBsDEkwrlz11FtOrr9ep35NwZLgGA4sY9epVyasu4hl1",
"t0cNSZc42Wzi5gYfBGkSN1sktJX3VzLOgSIpmkwawydO0iR+1WbKzmSNVBuLQjrtBBJlEu83JAe2HdzW",
"cNalM7cF77zJlDJOySi+zXS0Q/updjPvcU0mzvGTdyeyfa4sIaguRbB7UtDx0/rygmpGwYYzgxaQsYSF",
"3qldg5Ve0XNrMtep0j34Sf+zJ391K3VRVcTOFx+UcHa88IVavQ2sAkY3X/rCsUaFcRO7rKPlmhFmNLW7",
"qygSxM1zr+4ycUnm2uXwpC3mrDWpzk5t7oJjv5WyXoF8cNPfjAZcvfj61UJzbEJ3St7mU7Is++96KGTt",
"N3g+3sbD+xykFGmW++oSWLzxd92DuSH4DK/NjbCJm+H1wnVsfJThYQJIhqFT6SbZdpEj7Yj1FYdLF+Du",
"URw6QcUatgbpC4rDZmh23oNC0Ax64I4CWomYfARYPmDUl+C/OXxztHdI/3d1ePiB/e9/rB4q1v2YTmAm",
"3hAQuEeh8F0rEVKIx/AuSeE6Qf7IZlglzDVYvkMxwtPFYZb9N4rnVQG9UkyvzyNYdb+9Wn9g2XbsjjVr",
"iZFcjyOQhUW6pAIGngCNKroi++u5gR2jn3e5mGVnhndm+ObN8M627GzLF3n3gJcs/soEUJekvFm/r6EQ",
"a67nKahhFlH12OA1VC0X8R+OZOfOi7jNXsT1nYsUAexUuERnTHXG1M4YU/kyclG9Et+sU1V9xeDKS7vh",
"svRVCdN5HVZrlVgsgPXaJQc/1Z97lTwujVFJZpBb2iw7HptkwIE1b7ER1VsbrmTe3S5eqRyvZMFTu4AE",
"C200RC6thAF3uhbRTnHfOtVxp4p3Pa5pvXLEzTBQqRqe8xdCtdVKgRfDR/s7IfdnQle8w+4kV25+sVKf",
"m6EWtI3WUTVsQ5u6J9bN32hyy3ZBnnpOaDv8nVjcfHHHrUuoKQRdHZWv54mmJosLfmSzPJYWgZDI7vZg",
"xZQYZnEnhTcpheUOaBvQRv5a7YYNFqJqb47qEvhVnjQ78eskfoVB0mQTr1zk8izte0GSxaQhRIe1kTmv",
"ZHkB8ABQBMYRZNJXEzfm0/hnSHgWeHzCZtx50duUmmzHUxMWNmvBozcnFU4+nTfcckdfQNJiCQuL7J9h",
"mOKDIEtTWM/ZmJ8OeEOPdqtw7zWG6WdITsRga6Q7OlNLOmMQd4VuXr7QDQyyFJEnJsaDJLlH8DijsuvP",
"GyqqSo/biuQmyZ1tv4GMJ4hMs/FBAKJoDIJ7KzmfJLN5BAnkNH1B5/eM+ohOxMt8fGZDX1BcnsjhSwT+",
"9vBNw31CIOYNq/NOIQhFTbso4ZthrKGoxPpzCZkF3MkFFudwRB8mILWLghH9uhjiWNf2WGPwrB9nDLqW",
"CEuSSQTXQ29s6F+c3jj6VkxvOeJ+OXpD8QMi0KXwpbSGeQdmdDupbzrCFes7EHOtUYvrEznFT0QIy40p",
"LrCzF53VKsv9WsJeTnlXhhNigfYOQBDAObF73o7Zd6w8bGKSCrXpm8/7+OvxJ/HB+UTNhRlrqI+v3ER/",
"XRSAIi+O7creu9NXClkWxZqKbfR7O/riffx11T+jg6+AvvjKO/pqqE5PkbQAfUXJBMV2sjpLJthDsQeY",
"btyvMTDO2EDroSWmgun4G6og63SOjpLJBIYeirvj81Ydn4tqnVKN6zk5SiZJRhqYIcmIGzck2cv7egSN",
"JltWT6kj0gZjlFGPK9nO4GwMUzxF8xZHIK2T2zGIq5CveTfxjGitBG6etP15SEdRdyZa5EykY7CZJOcA",
"48ckrYlE4GJSSFJPtq8TqZdyzPXZGCdTEE/URNtkbAQMslAhqhPnOyTOOVkVKd2BiVI4oYIsrTv08Ra4",
"1iJRcTrrYhsJxjYxjERed821E3a6JCFXmwdHILhfyw3DiI68xRcMDaKm5Y3DA0yxAKG2dK9oJ+NXMEwf",
"DDbiIL5LPkPyTQy60sIlGqR5Roej/cP9Q1POCC1s5E/V9cahJslVzWJLoXI15PwdeikkWRoXkFeys6mU",
"yuIYxZN8ih97csi9ZM6fqOazyU17hONpktzviSiig5/iB4f3eFRTiNbVKCP+u/tTOzGQPYpHTbThIB7H",
"t2sSvk4vvLxeKL+X08nUGrojWtw4MceBwLPLIVk2lUX/6jlG2D3YNbHG1vLNaoLfOPQ89k2ghmJmKCa0",
"SV2VN1RgR21Xx55bxJ7MJ1DZorY8qniT/fHsUMfbYG1wCnN8mCoiBOsCTg06fnfCTVsH/okVd96wSkRp",
"5bUONZrrA0iZWU2pkATTGl9XLSHzVjtDy2twJTAEFPSGTVcIDGQSZZt7xOLIaxyyjtPMnCYYYhlmK2mT",
"8ssMp8wkKnzcKRVCi3PRVj5vaJPVQwHYva7a/Osq03FIo5gFHzf0miwsd05oYXK9hlc+C77s6XjrpXlL",
"f0K0DGO5mH3u3NXODtwKBltfXW2ODNeHztzqKnLZpo1DJ4lQNg87eWA1EJdjzgYz0Sm9Pt2kYh59xXgP",
"6qbDqilbpNPfBn42pLTkCSlXUG9o8WpDZsAmaZLNWZ7QHAS5UVZQWKcv8MlvzOGwZiGxZO5ueanUpe/e",
"QmtioXzhrQSXzCtjjQ2RKRHaZnpZKMHLVkquKwO77HuDO+bdxhmlDhj2GFdFgEBMFE8h7N1BEkxhaMsm",
"nQv+LTekBBksmDXmxXLFaPC2ShLTpYbpUsOsITVMK9EsZAN2uNUqaHInsSxia3bIBfMryOU1SzkZMLWc",
"KdjJu60yAXNSXNQELAf+jSFIYaoC/3rGUEAWScblQZZG/gfff755/n8BAAD//53jD4JGdgIA",
}
// GetSwagger returns the content of the embedded swagger specification file
+3 -1
View File
@@ -12,13 +12,15 @@ func ToWorkflow(
workflow *dbsqlc.Workflow,
version *dbsqlc.WorkflowVersion,
) *gen.Workflow {
res := &gen.Workflow{
Metadata: *toAPIMetadata(
sqlchelpers.UUIDToStr(workflow.ID),
workflow.CreatedAt.Time,
workflow.UpdatedAt.Time,
),
Name: workflow.Name,
Name: workflow.Name,
TenantId: sqlchelpers.UUIDToStr(workflow.TenantId),
}
res.IsPaused = &workflow.IsPaused.Bool
+1
View File
@@ -43,3 +43,4 @@ func OnCron(hatchet v1.HatchetClient) workflow.WorkflowDeclaration[OnCronInput,
return cronTask
}
+1
View File
@@ -49,3 +49,4 @@ func Priority(hatchet v1.HatchetClient) workflow.WorkflowDeclaration[PriorityInp
)
return workflow
}
+1
View File
@@ -93,3 +93,4 @@ func RateLimit(hatchet v1.HatchetClient) workflow.WorkflowDeclaration[RateLimitI
return rateLimitTask
}
+1
View File
@@ -81,3 +81,4 @@ func main() {
// ,
}
+1
View File
@@ -105,3 +105,4 @@ func main() {
}
// ,
}
@@ -95,3 +95,4 @@ func main() {
// ,
}
+2
View File
@@ -17,3 +17,5 @@ async def spawn(input: EmptyModel, ctx: Context) -> dict[str, Any]:
)
return {"results": result}
+1
View File
@@ -15,3 +15,4 @@ hatchet = Hatchet(
logger=root_logger,
),
)
+2
View File
@@ -37,3 +37,5 @@ def context_logger(input: EmptyModel, ctx: Context) -> dict[str, str]:
time.sleep(0.1)
return {"status": "success"}
@@ -19,3 +19,5 @@ def step1(input: EmptyModel, ctx: Context) -> dict[str, str]:
print("NON RESOURCE INTENSIVE PROCESS")
return {"status": "success"}
@@ -1,15 +0,0 @@
import { hatchet } from '../hatchet-client';
import { Input } from './workflow';
async function main() {
// ❓ Pushing an Event
const res = await hatchet.event.push<Input>('simple-event:create', {
Message: 'hello',
});
console.log(res.eventId);
}
if (require.main === module) {
main();
}
@@ -1,14 +0,0 @@
import { hatchet } from '../hatchet-client';
import { lower, upper } from './workflow';
async function main() {
const worker = await hatchet.worker('on-event-worker', {
workflows: [lower, upper],
});
await worker.start();
}
if (require.main === module) {
main();
}
@@ -1,51 +0,0 @@
import { hatchet } from '../hatchet-client';
export type Input = {
Message: string;
};
type LowerOutput = {
lower: {
TransformedMessage: string;
};
};
// ❓ Run workflow on event
export const lower = hatchet.workflow<Input, LowerOutput>({
name: 'lower',
on: {
// 👀 Declare the event that will trigger the workflow
event: 'simple-event:create',
},
});
lower.task({
name: 'lower',
fn: (input) => {
return {
TransformedMessage: input.Message.toLowerCase(),
};
},
});
type UpperOutput = {
upper: {
TransformedMessage: string;
};
};
export const upper = hatchet.workflow<Input, UpperOutput>({
name: 'upper',
on: {
event: 'simple-event:create',
},
});
upper.task({
name: 'upper',
fn: (input) => {
return {
TransformedMessage: input.Message.toUpperCase(),
};
},
});
+7 -1
View File
@@ -16,9 +16,15 @@
"ignorePatterns": ["dist"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": ["./tsconfig.json", "./tsconfig.node.json"]
"project": ["./tsconfig.json", "./tsconfig.node.json"],
"warnOnUnsupportedTypeScriptVersion": false
},
"plugins": ["react-refresh", "import", "unused-imports", "prettier"],
"settings": {
"react": {
"version": "18.3.1"
}
},
"rules": {
"@typescript-eslint/no-shadow": "off",
"@typescript-eslint/no-throw-literal": "off",
+9 -2
View File
@@ -5,7 +5,7 @@
"type": "module",
"packageManager": "pnpm@9.15.4",
"scripts": {
"dev": "vite",
"dev": "npm run sync-docs && npm run sync-examples && vite",
"build": "tsc && vite build",
"lint:check": "npm run eslint:check && npm run prettier:check",
"lint:fix": "npm run eslint:fix && npm run prettier:fix",
@@ -13,7 +13,9 @@
"eslint:fix": "eslint \"{src,apps,libs,test}/**/*.{ts,tsx,js}\" --fix",
"prettier:check": "prettier \"src/**/*.{ts,tsx}\" --list-different",
"prettier:fix": "prettier \"src/**/*.{ts,tsx}\" --write",
"preview": "vite preview"
"preview": "vite preview",
"sync-docs": "tsx src/next/lib/docs/sync-docs.ts && eslint \"src/next/lib/docs/generated/**/*.{ts,tsx,js}\" --fix --cache && prettier \"src/next/lib/docs/generated/**/*.{ts,tsx}\" --write",
"sync-examples": "cd ../snips/ && pnpm i && pnpm generate && pnpm run copy:app"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
@@ -21,6 +23,7 @@
"@lukemorales/query-key-factory": "^1.3.4",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
@@ -46,6 +49,7 @@
"@sentry/react": "^7.120.3",
"@sentry/vite-plugin": "^2.23.0",
"@tanstack/react-query": "^5.71.1",
"@tanstack/react-query-devtools": "^5.74.6",
"@tanstack/react-table": "^8.21.2",
"@visx/axis": "^3.12.0",
"@visx/brush": "^3.12.0",
@@ -88,6 +92,7 @@
"react-syntax-highlighter": "^15.6.1",
"reactflow": "^11.11.4",
"recharts": "^2.15.1",
"shiki": "^3.2.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"timeago-react": "^3.0.7",
@@ -120,6 +125,8 @@
"prettier": "^3.5.3",
"swagger-typescript-api": "^13.0.28",
"tailwindcss": "^3.4.17",
"ts-unused-exports": "^11.0.1",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vite": "^6.2.4",
"vite-plugin-eslint": "^1.8.1"
+712 -14
View File
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,7 @@ type ToasterToast = ToastProps & {
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
error?: string | Error | (string | Error)[];
};
const actionTypes = {
@@ -9,7 +9,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import api, { TenantVersion, User } from '@/lib/api';
import { useApiError } from '@/lib/hooks';
import { useMutation } from '@tanstack/react-query';
@@ -32,6 +32,7 @@ import { VersionInfo } from '@/pages/main/info/components/version-info';
import { useTenant } from '@/lib/atoms';
import { routes } from '@/router';
import { Banner, BannerProps } from './banner';
import { GrUpgrade } from 'react-icons/gr';
function HelpDropdown() {
const meta = useApiMeta();
@@ -114,6 +115,25 @@ function AccountDropdown({ user }: MainNavProps) {
onError: handleApiError,
});
useEffect(() => {
// FIXME remove this once we have a proper upgrade path
const upgrade = () => {
localStorage.setItem('next-ui', 'true');
window.location.href = '/next';
};
// Attach upgrade function to window object
(window as any).upgrade = upgrade;
return () => {
delete (window as any).upgrade;
};
}, []);
const canUpgrade = useMemo(() => {
return localStorage.getItem('next-ui') != undefined;
}, []);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -148,6 +168,17 @@ function AccountDropdown({ user }: MainNavProps) {
View Legacy V0 Data
</DropdownMenuItem>
)}
{canUpgrade && (
<DropdownMenuItem
onClick={() => {
localStorage.setItem('next-ui', 'true');
window.location.href = '/next';
}}
>
<GrUpgrade className="mr-2 h-4 w-4" />
Switch to Next UI
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => toggleTheme()}>
Toggle Theme
@@ -172,7 +203,6 @@ export default function MainNav({ user, setHasBanner }: MainNavProps) {
const { tenant } = useTenant();
const { pathname } = useLocation();
const navigate = useNavigate();
const [params] = useSearchParams();
const versionedRoutes = useMemo(
() =>
@@ -213,7 +243,7 @@ export default function MainNav({ user, setHasBanner }: MainNavProps) {
}
return;
}, [navigate, params, pathname, tenantVersion, versionedRoutes]);
}, [navigate, pathname, tenantVersion, versionedRoutes]);
useEffect(() => {
if (!setHasBanner) {
@@ -47,7 +47,12 @@ export function DateTimePicker({ date, setDate, label }: DateTimePickerProps) {
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? (
label + ': ' + format(date, 'PPP HH:mm:ss')
label +
': ' +
format(date, 'PPP HH:mm') +
' (' +
format(date, 'z') +
')'
) : (
<span>{label}</span>
)}
@@ -5,7 +5,7 @@ import 'monaco-themes/themes/Pastels on Dark.json';
import { useTheme } from '@/components/theme-provider';
interface CodeEditorProps {
code: string;
code?: string;
setCode?: (code: string | undefined) => void;
language: string;
className?: string;
@@ -17,7 +17,7 @@ interface CodeEditorProps {
}
export function CodeEditor({
code,
code = '',
setCode,
language,
className,
@@ -46,7 +46,7 @@ export function CodeEditor({
<Editor
beforeMount={setEditorTheme}
language={language}
value={code}
value={code || ''}
onChange={setCode}
width={width || '100%'}
height={height || '400px'}
@@ -74,7 +74,7 @@ export function CodeEditor({
{copy && (
<CopyToClipboard
className="absolute top-2 right-2"
text={code.trim()}
text={code?.trim() || ''}
/>
)}
</div>
@@ -82,21 +82,20 @@ export function CodeEditor({
}
export function DiffCodeEditor({
code,
code = '',
setCode,
language,
className,
height,
width,
copy,
originalValue,
originalValue = '',
wrapLines = true,
}: CodeEditorProps & {
originalValue: string;
}) {
const setEditorTheme = (monaco: Monaco) => {
monaco.editor.defineTheme('pastels-on-dark', getMonacoTheme());
monaco.editor.setTheme('pastels-on-dark');
};
@@ -114,7 +113,7 @@ export function DiffCodeEditor({
height={height || '400px'}
theme="pastels-on-dark"
original={originalValue}
modified={code}
modified={code || ''}
options={{
minimap: { enabled: false },
wordWrap: wrapLines ? 'on' : 'off',
@@ -132,7 +131,7 @@ export function DiffCodeEditor({
{copy && (
<CopyToClipboard
className="absolute top-2 right-2"
text={code.trim()}
text={code?.trim() || ''}
/>
)}
</div>
+1 -1
View File
@@ -38,7 +38,7 @@ const Step = ({
disabled={disabled}
onValueChange={(value) => setOpen(value === 'open')}
>
<AccordionItem value="open">
<AccordionItem value="open" className="border-none">
<AccordionTrigger
hideChevron={disabled}
className={disabled ? 'hover:no-underline cursor-default' : ''}
+129 -4
View File
@@ -123,7 +123,7 @@ body {
}
.spark:before {
content: "";
content: '';
position: absolute;
width: 200%;
aspect-ratio: 1;
@@ -157,8 +157,6 @@ body {
color: rgb(203 213 225);
}
/* rjsf JSON Schema Form style hack */
.rjsf {
/* Left side is not needed due to fieldset left margin */
@@ -239,7 +237,7 @@ body {
@apply bg-primary text-primary-foreground hover:bg-primary/90;
}
.rjsf textarea{
.rjsf textarea {
@apply flex h-20 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
/* END rjsf JSON Schema Form style hack */
@@ -252,3 +250,130 @@ body {
@apply bg-background text-foreground;
}
}
.shiki {
padding: 15px !important;
border-radius: 10px !important;
background-color: #ccc;
font-size: 0.8rem;
}
html.dark .nextra-code-block pre,
.dark .shiki {
background-color: transparent !important;
}
pre.shiki {
margin: 0;
counter-reset: line;
}
pre.shiki > code {
display: block;
}
pre.shiki > code > span {
counter-increment: line;
padding-left: 1rem;
position: relative;
min-height: 1.25em;
line-height: 1.25;
}
pre.shiki > code > span::before {
content: counter(line);
position: absolute;
left: -2rem;
width: 2rem;
text-align: right;
color: var(--muted-foreground);
opacity: 0.5;
user-select: none;
}
/* High contrast mode */
.contrast-more .shiki {
filter: contrast(1.5);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--border));
border-radius: 4px;
}
.dark ::-webkit-scrollbar-thumb {
background: hsl(var(--muted));
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted));
}
.dark ::-webkit-scrollbar-thumb:hover {
background: hsl(var(--border));
}
/* Code block line numbers */
.counter-reset-line {
counter-reset: line;
}
.counter-reset-line code {
display: block;
}
.counter-reset-line code span {
display: block;
position: relative;
padding-left: 2.5rem;
}
.counter-reset-line code span::before {
counter-increment: line;
content: counter(line);
position: absolute;
left: 0;
width: 2rem;
text-align: right;
color: var(--muted-foreground);
user-select: none;
padding-right: 0.5rem;
}
/* Code block line highlighting */
.highlighted-line {
background-color: rgba(255, 255, 0, 0.1);
display: block;
margin: 0 -1rem;
padding: 0 1rem;
border-left: 3px solid #fbbf24;
}
.dark .highlighted-line {
background-color: rgba(255, 255, 0, 0.05);
border-left-color: #fbbf24;
}
/* Code block string highlighting */
.highlighted-string {
background-color: #fbbf24;
color: #000;
padding: 0.1em 0.2em;
border-radius: 0.2em;
font-weight: bold;
}
.dark .highlighted-string {
background-color: #fbbf24;
color: #000;
}
@@ -1029,6 +1029,8 @@ export interface Workflow {
tags?: WorkflowTag[];
/** The jobs of the workflow. */
jobs?: Job[];
/** The tenant id of the workflow. */
tenantId: string;
}
export interface WorkflowVersionMeta {
+9
View File
@@ -265,6 +265,15 @@ export const queries = createQueryKeyStore({
queryFn: async () =>
(await api.v1WorkflowRunTaskEventsList(workflowRunId)).data,
}),
listTaskTimings: (workflowRunId: string, depth: number) => ({
queryKey: ['v1:workflow-run:list-tasks-timings', workflowRunId, depth],
queryFn: async () =>
(
await api.v1WorkflowRunGetTimings(workflowRunId, {
depth,
})
).data,
}),
details: (workflowRunId: string) => ({
queryKey: ['workflow-run-details:get', workflowRunId],
queryFn: async () => (await api.v1WorkflowRunGet(workflowRunId)).data,
+1 -1
View File
@@ -25,7 +25,7 @@ const lastTenantKey = 'lastTenant';
const lastTenantAtomInit = atom(getInitialValue<Tenant>(lastTenantKey));
export const lastTenantAtom = atom(
const lastTenantAtom = atom(
(get) => get(lastTenantAtomInit),
(_get, set, newVal: Tenant) => {
set(lastTenantAtomInit, newVal);
@@ -9,7 +9,7 @@ export interface ComputeType {
}
// Represents the maximum number of worker pools a tenant can create based on their plan
export const workerPoolLimits = {
const workerPoolLimits = {
free: 1,
starter: 2,
growth: 5,
@@ -17,7 +17,7 @@ export const workerPoolLimits = {
};
// Represents the maximum number of replicas per worker pool
export const replicaLimits = {
const replicaLimits = {
free: 2,
starter: 5,
growth: 20,
@@ -2,7 +2,7 @@ import { APICloudMetadata } from '@/lib/api/generated/cloud/data-contracts';
import { Tenant } from '@/lib/api/generated/data-contracts';
import { BillingContext } from '@/lib/atoms';
export type EvaluateContext = {
type EvaluateContext = {
tenant?: Tenant;
billing?: BillingContext;
meta?: APICloudMetadata;
+1 -15
View File
@@ -59,21 +59,7 @@ export function relativeDate(date?: string | number) {
return capitalize(rtf.format(value, time.unitOfTime));
}
export function timeBetween(start: string | number, end: string | number) {
const startUnixTime = new Date(start).getTime();
const endUnixTime = new Date(end).getTime();
if (!startUnixTime || !endUnixTime) {
return;
}
// Calculate difference
const difference = endUnixTime - startUnixTime;
return formatDuration(difference);
}
export function timeFrom(time: string | number, secondTime?: string | number) {
function timeFrom(time: string | number, secondTime?: string | number) {
// Get timestamps
const unixTime = new Date(time).getTime();
if (!unixTime) {
+2
View File
@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import queryClient from './query-client.tsx';
import Router from './router.tsx';
import * as Sentry from '@sentry/react';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
@@ -27,6 +28,7 @@ if (import.meta.env.VITE_SENTRY_DSN) {
ReactDOM.createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
<Router />
</QueryClientProvider>,
);
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg width="194" height="194" viewBox="0 0 194 194" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M152.845 0H40.2223C18.0081 0 0 18.0081 0 40.2223V152.845C0 175.059 18.0081 193.067 40.2223 193.067H152.845C175.059 193.067 193.067 175.059 193.067 152.845V40.2223C193.067 18.0081 175.059 0 152.845 0Z" fill="#3F16E4"/>
<path d="M47.0231 102.4L60.3522 114.615L107.151 60.9115L93.8217 48.6967L47.0231 102.4Z" fill="#FFFEFE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.0037 117.497L44.1061 105.795L60.3985 120.592L64.7456 115.701L64.7455 115.701H64.7457L83.1695 116.164L74.7153 125.692L60.7149 141.758L60.4073 141.476L34.0037 117.497Z" fill="#FFFEFE"/>
<path d="M147.029 90.4922L133.7 78.2773L86.9013 131.98L100.23 144.195L147.029 90.4922Z" fill="#FFFEFE"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M129.307 77.1908L110.883 76.7278L119.326 67.2114L133.337 51.1333L133.772 51.532L160.047 75.395L149.945 87.0971L133.653 72.3011L129.307 77.1908L129.307 77.1908Z" fill="#FFFEFE"/>
</svg>

After

Width:  |  Height:  |  Size: 1008 B

@@ -0,0 +1,61 @@
import { CenterStageLayout } from '@/next/components/layouts/center-stage.layout';
import { useEffect, useState } from 'react';
import { IoCloudOfflineSharp } from 'react-icons/io5';
import { Alert, AlertTitle, AlertDescription } from '../ui/alert';
interface ApiConnectionErrorProps {
retryInterval?: number;
loading?: boolean;
errorMessage?: string;
}
export function ApiConnectionError({
retryInterval,
errorMessage,
}: ApiConnectionErrorProps) {
const [countdown, setCountdown] = useState<number | null>(null);
// TODO countdown thing
useEffect(() => {
if (!retryInterval) {
setCountdown(null);
return;
}
setCountdown(Math.ceil(retryInterval / 1000));
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev === null || prev <= 1) {
clearInterval(interval);
return null;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [retryInterval]);
return (
<CenterStageLayout>
<div className="max-w-md mx-auto">
<Alert variant="destructive">
<IoCloudOfflineSharp className="h-4 w-4" />
<AlertTitle className="text-lg">Can't connect to API</AlertTitle>
<AlertDescription className="flex flex-col gap-8">
Hatchet cannot connect to the API. Please check your connection and
try again.
<div>
<code>{errorMessage}</code>
</div>
<div className="text-sm text-gray-500">
{countdown
? `Retrying in ${countdown} seconds...`
: 'Retrying...'}
</div>
</AlertDescription>
</Alert>
</div>
</CenterStageLayout>
);
}
@@ -0,0 +1,49 @@
import {
Alert,
AlertTitle,
AlertDescription,
} from '@/next/components/ui/alert';
import useTenant from '@/next/hooks/use-tenant';
import { FaLock } from 'react-icons/fa';
import { Button } from '../ui/button';
export function Unauthorized() {
return (
<div className="max-w-md mx-auto pt-20">
<Alert>
<FaLock className="h-4 w-4" />
<AlertTitle className="text-lg">Unauthorized</AlertTitle>
<AlertDescription>
You don't have access to this tenant. Please select a different tenant
or contact your administrator.
</AlertDescription>
</Alert>
</div>
);
}
export function WrongTenant({ desiredTenantId }: { desiredTenantId: string }) {
const { setTenant } = useTenant();
return (
<div className="max-w-md mx-auto pt-20">
<Alert>
<FaLock className="h-4 w-4" />
<AlertTitle className="text-lg">Wrong Tenant</AlertTitle>
<AlertDescription>
You are trying to access a run from a different tenant context, please
select the correct tenant to continue.
<div className="flex flex-row gap-2 mt-2">
<Button
onClick={() => {
setTenant(desiredTenantId);
}}
>
Switch Tenant
</Button>
</div>
</AlertDescription>
</Alert>
</div>
);
}
@@ -0,0 +1,11 @@
export default function BasicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex-grow h-full w-full">
<div className="p-4 md:p-8 lg:p-12">{children}</div>
</div>
);
}
@@ -0,0 +1,23 @@
import { cn } from '@/next/lib/utils';
import { ReactNode } from 'react';
interface CenterStageLayoutProps {
children: ReactNode;
className?: string;
}
export function CenterStageLayout({
children,
className = '',
}: CenterStageLayoutProps) {
return (
<div
className={cn(
'min-h-screen w-full flex items-center justify-center',
className,
)}
>
<div className="w-full max-w-7xl px-4 sm:px-6 lg:px-8">{children}</div>
</div>
);
}
@@ -0,0 +1,17 @@
import { ReactNode } from 'react';
interface SheetViewLayoutProps {
children: ReactNode;
sheet: ReactNode;
}
export function SheetViewLayout({ children, sheet }: SheetViewLayoutProps) {
return (
<div className="flex h-full w-full flex-row">
<div className="overflow-y-scroll flex-grow p-4 md:p-8 lg:p-12">
{children}
</div>
{sheet}
</div>
);
}
@@ -0,0 +1,33 @@
import { ReactNode } from 'react';
import { Card, CardContent } from '@/next/components/ui/card';
interface TwoColumnLayoutProps {
left: ReactNode;
right: ReactNode;
leftClassName?: string;
rightClassName?: string;
}
export function TwoColumnLayout({
left,
right,
leftClassName = '',
rightClassName = '',
}: TwoColumnLayoutProps) {
return (
<div className="flex h-[calc(100vh-4rem)]">
{/* Left panel */}
<div className={`flex-1 overflow-y-auto p-4 ${leftClassName}`}>
{left}
</div>
{/* Right panel */}
<div className={`w-1/2 flex-1 p-4 ${rightClassName}`}>
<Card className="h-full">
<CardContent className="space-y-6 py-4 h-full overflow-y-auto">
{right}
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,190 @@
import {
TenantInvite,
TenantMember,
TenantMemberRole,
} from '@/lib/api/generated/data-contracts';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/next/components/ui/table';
import { User } from 'lucide-react';
import { Button } from '@/next/components/ui/button';
import { Skeleton } from '@/next/components/ui/skeleton';
import useCan from '@/next/hooks/use-can';
import { members } from '@/next/lib/can/features/members.permissions';
import useUser from '@/next/hooks/use-user';
import { Badge } from '@/next/components/ui/badge';
import { useState } from 'react';
import useMembers from '@/next/hooks/use-members';
import { RemoveMemberForm } from '@/next/pages/authenticated/dashboard/settings/team/components/remove-member-form';
import { RevokeInviteForm } from '@/next/pages/authenticated/dashboard/settings/team/components/revoke-invite-form';
import { Separator } from '@radix-ui/react-separator';
import { InvitesTable } from '@/next/pages/authenticated/dashboard/settings/team/components/invites-table';
interface MembersTableProps {
emptyState?: React.ReactNode;
}
export function MembersTable({ emptyState }: MembersTableProps) {
const { can } = useCan();
const { data: user } = useUser();
const { data, isLoading, invites, isLoadingInvites, refetch } = useMembers();
const [removeMember, setRemoveMember] = useState<TenantMember | null>(null);
const [revokeInvite, setRevokeInvite] = useState<TenantInvite | null>(null);
if (isLoading) {
return <MembersTableSkeleton />;
}
if (data.length === 0 && emptyState) {
return emptyState;
}
return (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((member) => (
<TableRow key={member.metadata.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
{member.user.name || '-'}
{member.user.email === user?.email && (
<Badge variant="outline" className="ml-2">
You
</Badge>
)}
</div>
</TableCell>
<TableCell>{member.user.email}</TableCell>
<TableCell>{RoleMap[member.role]}</TableCell>
<TableCell>{formatDate(member.metadata.createdAt)}</TableCell>
<TableCell className="text-right">
{can(members.remove(member)) && (
<Button
variant="ghost"
size="sm"
onClick={() => setRemoveMember(member)}
className="h-8 px-2 lg:px-3"
>
Remove
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{invites && invites.length > 0 && (
<>
<Separator className="my-8" />
<h3 className="text-xl font-semibold leading-tight text-foreground mb-4">
Pending Invitations
</h3>
<InvitesTable
data={invites}
isLoading={isLoadingInvites}
onRevokeClick={(invite) => {
setRevokeInvite(invite);
}}
emptyState={
<div className="flex flex-col items-center justify-center gap-4 py-8">
<p className="text-sm text-muted-foreground">
No pending invitations.
</p>
</div>
}
/>
</>
)}
{removeMember && (
<RemoveMemberForm
member={removeMember}
close={() => {
setRemoveMember(null);
refetch();
}}
/>
)}
{revokeInvite && (
<RevokeInviteForm
invite={revokeInvite}
close={() => {
setRevokeInvite(null);
}}
/>
)}
</>
);
}
function MembersTableSkeleton() {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-5 w-[150px]" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-[200px]" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-[120px]" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-8 w-[80px] ml-auto" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function formatDate(date?: string) {
if (!date) {
return '-';
}
return new Date(date).toLocaleDateString();
}
const RoleMap: Record<TenantMemberRole, string> = {
[TenantMemberRole.ADMIN]: 'Admin',
[TenantMemberRole.MEMBER]: 'Member',
[TenantMemberRole.OWNER]: 'Owner',
};
@@ -0,0 +1,67 @@
import useApiMeta from '@/next/hooks/use-api-meta';
import useUser from '@/next/hooks/use-user';
import useTenant from '@/next/hooks/use-tenant';
import React, { PropsWithChildren, useEffect, useMemo } from 'react';
const AnalyticsProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { data: user } = useUser();
const meta = useApiMeta();
const [loaded, setLoaded] = React.useState(false);
const { tenant } = useTenant();
const config = useMemo(() => {
return meta.oss?.posthog;
}, [meta]);
useEffect(() => {
if (loaded) {
return;
}
if (tenant && tenant.analyticsOptOut) {
console.log(
'Skipping Analytics initialization due to opt-out, we respect user privacy.',
);
return;
}
if (!config || !tenant) {
return;
}
console.log('Initializing Analytics, opt out in settings.');
setLoaded(true);
const posthogScript = `
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init('${config.apiKey}',{
api_host:'${config.apiHost}',
session_recording: {
maskAllInputs: true,
maskTextSelector: "*"
}
})
`;
document.head.appendChild(document.createElement('script')).innerHTML =
posthogScript;
}, [config, loaded, tenant]);
useEffect(() => {
if (!config || !user) {
return;
}
setTimeout(() => {
(window as any).posthog.identify(
user.metadata.id, // Required. Replace 'distinct_id' with your user's unique identifier
{ email: user.email, name: user.name }, // $set, optional
{}, // $set_once, optional
);
});
}, [user, config, tenant]);
return children;
};
export default AnalyticsProvider;
@@ -0,0 +1,65 @@
import useApiMeta from '@/next/hooks/use-api-meta';
import useUser from '@/next/hooks/use-user';
import useTenant from '@/next/hooks/use-tenant';
import React, { PropsWithChildren, useEffect, useMemo } from 'react';
interface PylonWindow extends Window {
config?: {
chat_settings: {
app_id: string;
email: string;
name: string;
email_hash: string;
};
};
Pylon?: (method: string, ...args: any[]) => void;
}
const SupportChat: React.FC<PropsWithChildren> = ({ children }) => {
const { data: user } = useUser();
const meta = useApiMeta();
const { tenant } = useTenant();
const APP_ID = useMemo(
() => meta.oss?.pylonAppId ?? null,
[meta.oss?.pylonAppId],
);
useEffect(() => {
if (!APP_ID || !user) {
return;
}
// Initialize Pylon script
const script = document.createElement('script');
script.innerHTML = `(function(){var e=window;var t=document;var n=function(){n.e(arguments)};n.q=[];n.e=function(e){n.q.push(e)};e.Pylon=n;var r=function(){var e=t.createElement("script");e.setAttribute("type","text/javascript");e.setAttribute("async","true");e.setAttribute("src","https://widget.usepylon.com/widget/${APP_ID}");var n=t.getElementsByTagName("script")[0];n.parentNode.insertBefore(e,n)};if(t.readyState==="complete"){r()}else if(e.addEventListener){e.addEventListener("load",r,false)}})();`;
document.body.appendChild(script);
// Configure Pylon settings
(window as PylonWindow).config = {
chat_settings: {
app_id: APP_ID,
email: user.email || '',
name: user.name || '',
email_hash: user.emailHash || '',
},
};
// Set custom fields
(window as PylonWindow).Pylon?.('setNewIssueCustomFields', {
user_id: user.metadata.id,
tenant_name: tenant?.name,
tenant_slug: tenant?.slug,
tenant_id: tenant?.metadata?.id,
});
// Cleanup
return () => {
document.body.removeChild(script);
};
}, [APP_ID, user, tenant]);
return children;
};
export default SupportChat;
@@ -0,0 +1,239 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactFlow, {
Position,
MarkerType,
Node,
Edge,
BezierEdge,
} from 'reactflow';
import 'reactflow/dist/style.css';
import dagre from 'dagre';
import { useTheme } from '@/next/components/theme-provider';
import stepRunNode, { NodeData } from './step-run-node';
import { V1TaskStatus } from '@/lib/api';
import { RunDetailProvider, useRunDetail } from '@/next/hooks/use-run-detail';
import { useNavigate } from 'react-router-dom';
import { ROUTES } from '@/next/lib/routes';
const connectionLineStyleDark = { stroke: '#fff' };
const connectionLineStyleLight = { stroke: '#000' };
const nodeTypes = {
stepNode: stepRunNode,
};
const edgeTypes = {
smoothstep: BezierEdge,
};
interface WorkflowRunVisualizerProps {
workflowRunId: string;
onTaskSelect?: (taskId: string) => void;
}
function WorkflowRunVisualizer({
workflowRunId,
onTaskSelect,
}: WorkflowRunVisualizerProps) {
return (
<RunDetailProvider runId={workflowRunId}>
<WorkflowRunVisualizerContent
onTaskSelect={onTaskSelect}
workflowRunId={workflowRunId}
/>
</RunDetailProvider>
);
}
function WorkflowRunVisualizerContent({
workflowRunId,
onTaskSelect,
}: WorkflowRunVisualizerProps) {
const { theme } = useTheme();
const navigate = useNavigate();
const { data, isLoading, error } = useRunDetail();
const shape = useMemo(() => data?.shape || [], [data]);
const taskRuns = useMemo(() => data?.tasks || [], [data]);
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef(null);
useEffect(() => {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
}
};
}, []);
const setSelectedTaskRunId = useCallback(
(taskRunId: string) => {
if (onTaskSelect) {
onTaskSelect(taskRunId);
} else {
navigate(ROUTES.runs.taskDetail(workflowRunId, taskRunId));
}
},
[navigate, workflowRunId, onTaskSelect],
);
const edges: Edge[] = useMemo(
() =>
(
shape.flatMap((shapeItem) =>
shapeItem.childrenStepIds.map((childId) => {
const child = shape.find((t) => t.stepId === childId);
const childTaskRun = taskRuns.find((t) => t.stepId === childId);
if (!child) {
return null;
}
return {
id: `${shapeItem.stepId}-${childId}`,
source: shapeItem.stepId,
target: childId,
animated: childTaskRun?.status === V1TaskStatus.RUNNING,
style:
theme === 'dark'
? connectionLineStyleDark
: connectionLineStyleLight,
markerEnd: {
type: MarkerType.ArrowClosed,
},
type: 'smoothstep',
};
}),
) || []
).filter((x) => Boolean(x)) as Edge[],
[shape, theme, taskRuns],
);
const nodes: Node[] = useMemo(
() =>
shape.map((shapeItem) => {
const hasParent = shape.some((s) =>
s.childrenStepIds.includes(shapeItem.stepId),
);
const hasChild = shape.some((s) => s.stepId === shapeItem.stepId);
const task = taskRuns.find((t) => t.stepId === shapeItem.stepId);
const data: NodeData = {
taskRun: task,
graphVariant:
hasParent && hasChild
? 'default'
: hasChild
? 'output_only'
: 'input_only',
onClick: () => task && setSelectedTaskRunId(task.metadata.id),
childWorkflowsCount: task?.numSpawnedChildren || 0,
taskName: shapeItem.taskName,
};
return {
id: shapeItem.stepId,
type: 'stepNode',
position: { x: 0, y: 0 },
data,
selectable: true,
};
}) || [],
[shape, taskRuns, setSelectedTaskRunId],
);
const nodeWidth = 230;
const nodeHeight = 70;
const getLayoutedElements = (
nodes: Node[],
edges: Edge[],
direction = 'LR',
) => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
const isHorizontal = direction === 'LR';
dagreGraph.setGraph({ rankdir: direction });
nodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target);
});
dagre.layout(dagreGraph);
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
node.targetPosition = isHorizontal ? Position.Left : Position.Top;
node.sourcePosition = isHorizontal ? Position.Right : Position.Bottom;
node.position = {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2,
};
return { ...node };
});
return { nodes: layoutedNodes, edges };
};
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(
() => getLayoutedElements(nodes, edges),
[nodes, edges],
);
if (
isLoading ||
error ||
layoutedNodes.length === 0 ||
layoutedEdges.length === 0
) {
return null;
}
return (
<div ref={containerRef} className="w-full h-[300px]">
<ReactFlow
key={containerWidth}
nodes={layoutedNodes}
edges={layoutedEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
proOptions={{
hideAttribution: true,
}}
onNodeClick={(_, node) => {
const task = taskRuns.find((t) => t.stepId === node.id);
if (task) {
setSelectedTaskRunId(task.metadata.id);
}
}}
maxZoom={1}
connectionLineStyle={
theme === 'dark' ? connectionLineStyleDark : connectionLineStyleLight
}
snapToGrid={true}
/>
</div>
);
}
export default WorkflowRunVisualizer;
@@ -0,0 +1,112 @@
import { V1TaskStatus, V1TaskSummary } from '@/lib/api';
import { cn } from '@/next/lib/utils';
import { memo } from 'react';
import { Handle, Position } from 'reactflow';
import { Link } from 'react-router-dom';
import { Duration } from '@/next/components/ui/duration';
import { RunsBadge } from '../runs-badge';
import { ROUTES } from '@/next/lib/routes';
enum TabOption {
Output = 'output',
ChildWorkflowRuns = 'child-workflow-runs',
Input = 'input',
Logs = 'logs',
}
export type NodeData = {
taskRun: V1TaskSummary | undefined;
graphVariant: 'default' | 'input_only' | 'output_only' | 'none';
onClick: (defaultOpenTab?: TabOption) => void;
childWorkflowsCount: number;
taskName: string;
};
// eslint-disable-next-line react/display-name
export default memo(({ data }: { data: NodeData }) => {
const variant = data.graphVariant;
return (
<div className="flex flex-col justify-start min-w-fit grow">
{(variant == 'default' || variant == 'input_only') && (
<Handle
type="target"
position={Position.Left}
style={{ visibility: 'hidden' }}
isConnectable={false}
/>
)}
<div
className={cn(
`step-run-card shadow-md rounded-sm py-3 px-2 mb-1 w-full text-xs text-[#050c1c] dark:text-[#ffffff] font-semibold font-mono`,
`transition-all duration-300 ease-in-out`,
`cursor-pointer`,
`flex flex-row items-center justify-between gap-4 border-2 dark:border-[1px]`,
`bg-[#ffffff] dark:bg-[#050c1c]`,
'hover:opacity-100 opacity-80',
'h-[30px]',
)}
onClick={() => data.onClick()}
>
{data.taskRun?.status == V1TaskStatus.RUNNING && (
<span className="spark mask-gradient animate-flip before:animate-rotate absolute inset-0 h-[100%] w-[100%] overflow-hidden [mask:linear-gradient(#ccc,_transparent_50%)] before:absolute before:aspect-square before:w-[200%] before:rotate-[-90deg] before:bg-[conic-gradient(from_0deg,transparent_0_340deg,#ccc_360deg)] before:content-[''] before:[inset:0_auto_auto_50%] before:[translate:-50%_-15%]" />
)}
<span className="step-run-backdrop absolute inset-[1px] bg-background transition-colors duration-200" />
<div className="z-10 flex flex-row items-center justify-between gap-4 w-full">
<div className="flex flex-row items-center justify-start gap-2 z-10">
<RunsBadge status={data.taskRun?.status} variant="xs" />
<div className="truncate flex-grow max-w-[160px]">
{data.taskName}
</div>
</div>
{data.taskRun?.finishedAt && data.taskRun?.startedAt && (
<div className="text-xs text-gray-500 dark:text-gray-400">
<Duration
className="text-xs"
start={data.taskRun?.startedAt}
end={data.taskRun?.finishedAt}
status={data.taskRun?.status}
/>
</div>
)}
</div>
{(variant == 'default' || variant == 'output_only') && (
<Handle
type="source"
position={Position.Right}
style={{ visibility: 'hidden' }}
isConnectable={false}
/>
)}
</div>
{data.childWorkflowsCount && data.taskRun ? (
<Link
to={{
pathname: ROUTES.runs.list,
search: new URLSearchParams({
...Object.fromEntries(new URLSearchParams(location.search)),
// [queryParamNames.parentTaskExternalId]: data.taskRun.metadata.id,
}).toString(),
}}
>
<div
key={`${data.taskRun.metadata.id}-child-workflows`}
className={cn(
`w-[calc(100%-1rem)] box-border shadow-md ml-4 rounded-sm py-3 px-2 mb-1 text-xs text-[#050c1c] dark:text-[#ffffff] font-semibold font-mono`,
`transition-all duration-300 ease-in-out`,
`cursor-pointer`,
`flex flex-row items-center justify-start border-2 dark:border-[1px]`,
`bg-[#ffffff] dark:bg-[#050c1c]`,
'h-[30px]',
)}
>
<div className="truncate flex-grow">
{data.taskName}: {data.childWorkflowsCount} children
</div>
</div>{' '}
</Link>
) : null}
</div>
);
});
@@ -0,0 +1,509 @@
import {
V1WorkflowRun,
V1TaskEvent,
V1TaskEventType,
V1TaskSummary,
} from '@/lib/api';
import { useRunDetail } from '@/next/hooks/use-run-detail';
import { cn } from '@/next/lib/utils';
import { RunStatusConfigs } from '../runs-badge';
import { WorkflowRunStatus } from '@/lib/api';
import {
CheckCircle2,
PlayCircle,
Clock,
AlertCircle,
XCircle,
RefreshCw,
PauseCircle,
Timer,
UserCog,
Unlock,
RotateCw,
Send,
AlertTriangle,
Bell,
Plus,
SkipForward,
ArrowUpRight,
Info,
Cpu,
Bug,
CpuIcon,
} from 'lucide-react';
import { VscJson } from 'react-icons/vsc';
import { Button } from '@/next/components/ui/button';
import { Time } from '@/next/components/ui/time';
import { useMemo } from 'react';
import { RunId } from '../run-id';
import {
FilterGroup,
FilterText,
FilterSelect,
} from '@/next/components/ui/filters/filters';
import { useFilters } from '@/next/hooks/utils/use-filters';
import { ROUTES } from '@/next/lib/routes';
interface RunEventLogProps {
workflow: V1WorkflowRun;
onTaskSelect?: (
taskId: string,
options?: Parameters<typeof ROUTES.runs.taskDetail>[2],
) => void;
}
type EventConfig = {
icon: React.ComponentType<{ className?: string }>;
message: string | ((event: V1TaskEvent) => string);
showWorkerButton?: boolean;
status: WorkflowRunStatus;
title: string;
};
const LogEventType = 'LOG_LINE' as V1TaskEventType;
interface ActivityFilters {
search?: string;
eventType?: V1TaskEventType[];
taskId?: string[];
}
const EVENT_CONFIG: Record<V1TaskEventType, EventConfig> = {
[V1TaskEventType.FINISHED]: {
icon: CheckCircle2,
message: 'Task completed successfully',
status: WorkflowRunStatus.SUCCEEDED,
title: 'Task Finished',
},
[V1TaskEventType.STARTED]: {
icon: PlayCircle,
message: 'Task execution started',
status: WorkflowRunStatus.RUNNING,
title: 'Task Started',
},
[V1TaskEventType.ASSIGNED]: {
icon: Cpu,
message: (event) => `Assigned to worker ${event.workerId}`,
showWorkerButton: true,
status: WorkflowRunStatus.PENDING,
title: 'Task Assigned',
},
[V1TaskEventType.QUEUED]: {
icon: Clock,
message: 'Task queued for execution',
status: WorkflowRunStatus.PENDING,
title: 'Task Queued',
},
[V1TaskEventType.FAILED]: {
icon: AlertCircle,
message: (event) => event.errorMessage || 'Task failed',
status: WorkflowRunStatus.FAILED,
title: 'Task Failed',
},
[V1TaskEventType.CANCELLED]: {
icon: XCircle,
message: 'Task cancelled',
status: WorkflowRunStatus.CANCELLED,
title: 'Task Cancelled',
},
[V1TaskEventType.RETRYING]: {
icon: RefreshCw,
message: 'Retrying task',
status: WorkflowRunStatus.BACKOFF,
title: 'Task Retrying',
},
[V1TaskEventType.TIMED_OUT]: {
icon: Timer,
message: 'Task timed out',
status: WorkflowRunStatus.FAILED,
title: 'Task Timed Out',
},
[V1TaskEventType.REASSIGNED]: {
icon: UserCog,
message:
'Reassigned as the worker became inactive (did not heartbeat for more than 30 seconds)',
showWorkerButton: true,
status: WorkflowRunStatus.RUNNING,
title: 'Task Reassigned',
},
[V1TaskEventType.SLOT_RELEASED]: {
icon: Unlock,
message: 'Worker slot released',
status: WorkflowRunStatus.PENDING,
title: 'Slot Released',
},
[V1TaskEventType.TIMEOUT_REFRESHED]: {
icon: RotateCw,
message: 'Task timeout refreshed',
status: WorkflowRunStatus.RUNNING,
title: 'Timeout Refreshed',
},
[V1TaskEventType.RETRIED_BY_USER]: {
icon: RefreshCw,
message: 'Task retried by user',
status: WorkflowRunStatus.BACKOFF,
title: 'User Retry',
},
[V1TaskEventType.SENT_TO_WORKER]: {
icon: Send,
message: 'Task sent to worker',
status: WorkflowRunStatus.RUNNING,
title: 'Sent to Worker',
},
[V1TaskEventType.RATE_LIMIT_ERROR]: {
icon: AlertTriangle,
message: 'Rate limit error occurred',
status: WorkflowRunStatus.FAILED,
title: 'Rate Limit Error',
},
[V1TaskEventType.ACKNOWLEDGED]: {
icon: Bell,
message: 'Task acknowledged by worker',
status: WorkflowRunStatus.RUNNING,
title: 'Task Acknowledged',
},
[V1TaskEventType.CREATED]: {
icon: Plus,
message: 'Task created',
status: WorkflowRunStatus.PENDING,
title: 'Task Created',
},
[V1TaskEventType.SKIPPED]: {
icon: SkipForward,
message: 'Task skipped',
status: WorkflowRunStatus.SUCCEEDED,
title: 'Task Skipped',
},
[V1TaskEventType.REQUEUED_NO_WORKER]: {
icon: PauseCircle,
message: 'Task requeued - no available worker',
status: WorkflowRunStatus.BACKOFF,
title: 'Requeued - No Worker',
},
[V1TaskEventType.REQUEUED_RATE_LIMIT]: {
icon: PauseCircle,
message: 'Task requeued - rate limit reached',
status: WorkflowRunStatus.BACKOFF,
title: 'Requeued - Rate Limit',
},
[V1TaskEventType.SCHEDULING_TIMED_OUT]: {
icon: PauseCircle,
message: 'Task scheduling timed out',
status: WorkflowRunStatus.FAILED,
title: 'Scheduling Timeout',
},
[LogEventType]: {
icon: Info,
message: (event: V1TaskEvent) => event.message || 'Log message',
status: WorkflowRunStatus.PENDING,
title: 'Log Message',
},
} as const;
interface EventIconProps {
eventType: V1TaskEventType;
className?: string;
}
const EventIcon = ({ eventType, className }: EventIconProps) => {
const config = EVENT_CONFIG[eventType];
const textColor = RunStatusConfigs[config.status]?.primary || 'text-gray-500';
return (
<config.icon className={cn('h-2 w-2 rounded-full', textColor, className)} />
);
};
interface EventMessageProps {
event: V1TaskEvent;
onTaskSelect?: (
taskId: string,
options?: Parameters<typeof ROUTES.runs.taskDetail>[2],
) => void;
}
const EventMessage = ({ event, onTaskSelect }: EventMessageProps) => {
const config = EVENT_CONFIG[event.eventType];
const message =
typeof config.message === 'function'
? config.message(event)
: config.message;
if (event.eventType === V1TaskEventType.FAILED) {
let error = { message: 'Unknown error' };
try {
error = event.errorMessage
? JSON.parse(event.errorMessage)
: { message: 'Unknown error' };
} catch {
error = { message: 'Unknown error' };
}
return (
<div className="flex justify-between items-center gap-2">
<span className="text-xs text-destructive">{error.message}</span>
{onTaskSelect && (
<Button
variant="outline"
size="sm"
className="h-5 px-1 text-xs text-destructive hover:text-destructive/80 border-destructive/50"
onClick={(e) => {
e.stopPropagation();
onTaskSelect(event.taskId);
}}
>
<Bug className="h-3 w-3" />
</Button>
)}
</div>
);
}
if (event.eventType === V1TaskEventType.FINISHED) {
return (
<div className="flex justify-between items-center gap-2">
<span className="text-xs">{message}</span>
{onTaskSelect && (
<Button
variant="outline"
size="sm"
className="h-5 px-1 text-xs text-muted-foreground hover:text-muted-foreground/80 border-muted-foreground/50"
onClick={(e) => {
e.stopPropagation();
onTaskSelect(event.taskId);
}}
>
<VscJson className="h-3 w-3" />
</Button>
)}
</div>
);
}
return (
<div className="flex justify-between items-center gap-2">
<span className="text-xs">{message}</span>
{config.showWorkerButton && event.workerId && onTaskSelect && (
<Button
variant="outline"
size="sm"
className="h-5 p-1 text-xs text-muted-foreground hover:text-muted-foreground/80 border-muted-foreground/50"
onClick={(e) => {
e.stopPropagation();
onTaskSelect(event.taskId, { task_tab: 'worker' });
}}
>
<CpuIcon className="h-3 w-3" />
</Button>
)}
</div>
);
};
export function RunEventLog({ onTaskSelect }: RunEventLogProps) {
const { data, activity } = useRunDetail();
const { filters } = useFilters<ActivityFilters>();
const tasks = useMemo(() => {
return data?.tasks.reduce(
(acc, task) => {
acc[task.metadata.id] = task;
return acc;
},
{} as Record<string, V1TaskSummary>,
);
}, [data]);
const taskOptions = useMemo(() => {
if (!tasks) {
return [];
}
return Object.entries(tasks).map(([id, task]) => ({
label: task.displayName || `Task-${id.substring(0, 8)}`,
value: id,
}));
}, [tasks]);
const mergedActivity = useMemo<V1TaskEvent[]>(() => {
const events = activity?.events || [];
const logs = activity?.logs || [];
const logEvents: V1TaskEvent[] = logs.map((log, index) => ({
id: index + 1,
taskId: log.taskId,
timestamp: log.createdAt,
eventType: LogEventType,
message: log.message,
metadata: log.metadata,
}));
const allEvents = [...events, ...logEvents];
return allEvents.sort((a, b) => {
const timeA = new Date(a.timestamp).getTime();
const timeB = new Date(b.timestamp).getTime();
// First sort by timestamp (newest first)
if (timeA !== timeB) {
return timeB - timeA;
}
// Then sort by event type (STARTED first)
if (
a.eventType === V1TaskEventType.STARTED &&
b.eventType !== V1TaskEventType.STARTED
) {
return 1;
}
if (
a.eventType !== V1TaskEventType.STARTED &&
b.eventType === V1TaskEventType.STARTED
) {
return -1;
}
return 0;
});
}, [activity?.events, activity?.logs]);
const eventTypeOptions = useMemo(() => {
return Object.entries(EVENT_CONFIG).map(([type, config]) => ({
label: config.title,
value: type as V1TaskEventType,
}));
}, []);
const filteredActivity = useMemo(() => {
let filtered = mergedActivity;
if (filters.search) {
const searchLower = filters.search.toLowerCase();
filtered = filtered.filter((event) => {
const config = EVENT_CONFIG[event.eventType];
const message =
typeof config.message === 'function'
? config.message(event)
: config.message;
return (
message.toLowerCase().includes(searchLower) ||
event.eventType.toLowerCase().includes(searchLower) ||
(event.taskId && event.taskId.toLowerCase().includes(searchLower)) ||
(event.workerId && event.workerId.toLowerCase().includes(searchLower))
);
});
}
if (filters.eventType?.length) {
filtered = filtered.filter((event) =>
filters.eventType?.includes(event.eventType),
);
}
if (filters.taskId?.length) {
filtered = filtered.filter(
(event) => event.taskId && filters.taskId?.includes(event.taskId),
);
}
return filtered;
}, [mergedActivity, filters.search, filters.eventType, filters.taskId]);
return (
<div className="space-y-2">
<FilterGroup>
<FilterText<ActivityFilters>
name="search"
placeholder="Search activity..."
/>
<FilterSelect<ActivityFilters, V1TaskEventType>
name="eventType"
placeholder="Event Type"
options={eventTypeOptions}
multi
/>
{taskOptions.length > 1 && (
<FilterSelect<ActivityFilters, string>
name="taskId"
placeholder="Task"
options={taskOptions}
multi
/>
)}
</FilterGroup>
<div className="space-y-0.5 bg-background p-1 rounded-md">
{filteredActivity?.map((event) => (
<div
key={event.id}
className={cn(
'flex flex-col gap-0.5 rounded-sm p-1 text-xs font-mono',
'hover:bg-muted/50 cursor-pointer transition-colors',
'group relative',
)}
onClick={() => onTaskSelect?.(event.taskId)}
>
<div className="flex flex-col gap-0.5 w-full">
<div className="flex items-center gap-1.5">
<div className="flex flex-col min-w-0 w-full">
<div className="flex items-center gap-2 flex-wrap">
<EventIcon
eventType={event.eventType}
className="shrink-0"
/>
<Time
date={event.timestamp}
variant="timestamp"
className="text-gray-500 shrink-0"
asChild
>
<span />
</Time>
<p className="text-gray-500 shrink-0">
{tasks?.[event.taskId] && (
<RunId taskRun={tasks[event.taskId] as any} />
)}
/
</p>
{event.eventType === LogEventType ? (
<EventMessage event={event} onTaskSelect={onTaskSelect} />
) : (
<>
<p
className={cn(
'font-medium shrink-0',
RunStatusConfigs[
EVENT_CONFIG[event.eventType].status
]?.primary || 'text-gray-500',
'bg-transparent',
)}
>
{event.eventType}
</p>
<div className="text-gray-500 break-all">
<EventMessage
event={event}
onTaskSelect={onTaskSelect}
/>
</div>
</>
)}
</div>
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="absolute right-2 top-1 h-4 w-4 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onTaskSelect?.(event.taskId);
}}
>
<ArrowUpRight className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,104 @@
import { V1TaskSummary, V1WorkflowRun, V1WorkflowType } from '@/lib/api';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/next/components/ui/tooltip';
import { Link, useNavigate } from 'react-router-dom';
import { ROUTES } from '@/next/lib/routes';
interface RunIdProps {
wfRun?: V1WorkflowRun;
taskRun?: V1TaskSummary;
onClick?: () => void;
}
export function RunId({ wfRun, taskRun, onClick }: RunIdProps) {
const isTaskRun = taskRun !== undefined;
const navigate = useNavigate();
if (taskRun?.displayName.startsWith('leaf')) {
// Debugging code removed.
}
const url = !isTaskRun
? ROUTES.runs.detail(wfRun?.metadata.id || '')
: taskRun?.type == V1WorkflowType.TASK
? undefined
: ROUTES.runs.taskDetail(
taskRun?.workflowRunExternalId || '',
taskRun?.taskExternalId || '',
);
const name = isTaskRun
? getFriendlyTaskRunId(taskRun)
: getFriendlyWorkflowRunId(wfRun);
const handleDoubleClick = () => {
if (url) {
navigate(url);
}
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
{url && !onClick ? (
<Link to={url} className="hover:underline text-foreground">
{name}
</Link>
) : (
<span
className={onClick ? 'cursor-pointer' : ''}
onClick={onClick}
onDoubleClick={handleDoubleClick}
>
{name}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent className="bg-muted">
<div className="font-mono text-foreground">
{wfRun?.metadata.id || taskRun?.metadata.id || ''}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function splitTime(runId?: string) {
if (!runId) {
return;
}
return runId.split('-').slice(0, -1).join('-');
}
function getFriendlyTaskRunId(run?: V1TaskSummary) {
if (!run) {
return;
}
if (run.actionId) {
const runIdPrefix = run.metadata.id.split('-')[0];
return run.actionId?.split(':')?.at(1) + '-' + runIdPrefix;
}
return getFriendlyWorkflowRunId(run);
}
export function getFriendlyWorkflowRunId(run?: V1WorkflowRun) {
if (!run) {
return;
}
const displayNameParts = splitTime(run.displayName);
const runIdPrefix = run.metadata.id.split('-')[0];
return displayNameParts + '-' + runIdPrefix;
}
@@ -0,0 +1,91 @@
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/next/components/ui/card';
import { Code } from '@/next/components/ui/code';
import { V1TaskStatus } from '@/lib/api';
import { RunsBadge } from './runs-badge';
import { cn } from '@/next/lib/utils';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/next/components/ui/collapsible';
import { ChevronDownIcon } from '@radix-ui/react-icons';
import { useState } from 'react';
type RunOutputCardVariant = 'input' | 'output' | 'metadata';
interface RunOutputCardProps {
title: string;
description?: string;
output?: any;
status?: V1TaskStatus;
variant: RunOutputCardVariant;
error?: string;
collapsed?: boolean;
actions?: React.ReactNode;
}
export function RunDataCard({
title,
description,
output,
status,
variant,
error,
collapsed = false,
actions,
}: RunOutputCardProps) {
const [isOpen, setIsOpen] = useState(!collapsed);
const errorData = error ? JSON.parse(error) : null;
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="py-3 px-4 cursor-pointer hover:bg-muted/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ChevronDownIcon
className={cn(
'h-4 w-4 transition-transform duration-200',
isOpen ? 'rotate-0' : '-rotate-90',
)}
/>
<CardTitle className="text-sm font-medium">
{error ? 'Error' : title}
</CardTitle>
{variant === 'output' && status && (
<RunsBadge status={status} variant="xs" />
)}
</div>
{actions}
</div>
{error ? (
<CardDescription className="text-base text-destructive">
{errorData?.message}
</CardDescription>
) : description ? (
<CardDescription className="text-xs">
{description}
</CardDescription>
) : null}
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className={cn(error && 'border-destructive')}>
<Code
language={error ? 'text' : 'json'}
value={error ? errorData?.stack : JSON.stringify(output, null, 2)}
/>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
);
}
@@ -0,0 +1,137 @@
import { Badge, BadgeProps } from '@/next/components/ui/badge';
import { V1TaskStatus, WorkflowRunStatus } from '@/lib/api';
import { cn } from '@/next/lib/utils';
interface RunsBadgeProps extends BadgeProps {
status?: StatusKey;
count?: number;
percentage?: number;
animated?: boolean;
isLoading?: boolean;
}
type StatusConfig = {
colors: string;
primary: string;
primaryOKLCH: string;
label: string;
};
type StatusKey = WorkflowRunStatus | V1TaskStatus | 'QueueMetrics';
export const RunStatusConfigs: Record<StatusKey, StatusConfig> = {
[WorkflowRunStatus.RUNNING]: {
colors:
'text-indigo-800 dark:text-indigo-300 bg-indigo-500/20 ring-indigo-500/30',
primary: 'text-indigo-500 bg-indigo-500',
primaryOKLCH: 'oklch(0.585 0.233 277.117)',
label: 'Running',
},
[WorkflowRunStatus.SUCCEEDED]: {
colors:
'text-green-800 dark:text-green-300 bg-green-500/20 ring-green-500/30',
primary: 'text-green-500 bg-green-500',
primaryOKLCH: 'oklch(0.723 0.219 149.579)',
label: 'Succeeded',
},
[V1TaskStatus.COMPLETED]: {
colors:
'text-green-800 dark:text-green-300 bg-green-500/20 ring-green-500/30',
primary: 'text-green-500 bg-green-500',
primaryOKLCH: 'oklch(0.723 0.219 149.579)',
label: 'Succeeded',
},
[WorkflowRunStatus.FAILED]: {
colors: 'text-red-800 dark:text-red-300 bg-red-500/20 ring-red-500',
primary: 'text-red-500 bg-red-500',
primaryOKLCH: 'oklch(0.637 0.237 25.331)',
label: 'Failed',
},
[WorkflowRunStatus.CANCELLED]: {
colors: 'text-gray-800 dark:text-gray-300 bg-gray-500/20 ring-gray-500/30',
primary: 'text-gray-500 bg-gray-500',
primaryOKLCH: 'oklch(0.551 0.027 264.364)',
label: 'Cancelled',
},
[V1TaskStatus.QUEUED]: {
colors:
'text-yellow-800 dark:text-yellow-300 bg-yellow-500/20 ring-yellow-500/30',
primary: 'text-yellow-500 bg-yellow-500',
primaryOKLCH: 'oklch(0.795 0.184 86.047)',
label: 'Queued',
},
[WorkflowRunStatus.BACKOFF]: {
colors:
'text-orange-800 dark:text-orange-300 bg-orange-500/20 ring-orange-500/30',
primary: 'text-orange-500 bg-orange-500',
primaryOKLCH: 'oklch(0.705 0.213 47.604)',
label: 'Backoff',
},
[WorkflowRunStatus.PENDING]: {
colors: 'text-gray-800 dark:text-gray-300 bg-gray-500/20 ring-gray-500/30',
primary: 'text-gray-500 bg-gray-500',
primaryOKLCH: 'oklch(0.551 0.027 264.364)',
label: 'Pending',
},
QueueMetrics: {
colors: 'text-gray-800 dark:text-gray-300 bg-gray-500/20 ring-gray-500/30',
primary: 'text-gray-500 bg-gray-500',
primaryOKLCH: 'oklch(0.551 0.027 264.364)',
label: 'Queue Metrics',
},
};
export function RunsBadge({
status,
variant,
count,
percentage,
animated,
isLoading,
className,
...props
}: RunsBadgeProps) {
const config = !status
? RunStatusConfigs.PENDING
: RunStatusConfigs[status] || {
colors: 'bg-gray-50 text-gray-700 border-gray-200',
primary: 'text-gray-500',
label: 'Pending',
};
const content =
variant === 'detail' ? (
<>
{count?.toLocaleString('en-US')} {config.label} ({percentage}%)
</>
) : variant !== 'xs' ? (
config.label
) : null;
return (
<Badge
className={cn(
variant === 'xs' ? 'p-0 w-2 h-2' : 'px-3 py-1',
isLoading
? 'animate-pulse bg-gray-200/20 text-transparent'
: variant === 'xs'
? config.primary
: config.colors,
'text-xs font-medium rounded-md border-transparent',
className,
)}
tooltipContent={status}
animated={
isLoading
? false
: animated !== undefined
? animated
: status === V1TaskStatus.RUNNING
}
variant={variant}
{...props}
>
{content}
</Badge>
);
}
@@ -0,0 +1,42 @@
import { Skeleton } from '@/next/components/ui/skeleton';
import { DataPoint } from '@/next/components/ui/charts/zoomable';
import { ZoomableChart } from '@/next/components/ui/charts/zoomable';
import { useRuns } from '@/next/hooks/use-runs';
function GetWorkflowChart() {
const { histogram, timeRange } = useRuns();
if (histogram.isLoading) {
return <Skeleton className="w-full h-36" />;
}
return (
<div className="">
<ZoomableChart
kind="bar"
data={
histogram.data?.results?.map(
(result: any): DataPoint<'SUCCEEDED' | 'FAILED'> => ({
date: result.time,
SUCCEEDED: result.SUCCEEDED,
FAILED: result.FAILED,
}),
) || []
}
colors={{
SUCCEEDED: 'rgb(34 197 94 / 0.5)',
FAILED: 'hsl(var(--destructive))',
}}
zoom={(start, end) => {
timeRange.setTimeFilter({
startTime: start,
endTime: end,
});
}}
showYAxis={false}
/>
</div>
);
}
export default GetWorkflowChart;
@@ -0,0 +1,160 @@
import { V1TaskRunMetrics, V1TaskStatus } from '@/lib/api';
import { RunsBadge } from '../runs-badge';
import { percent } from '@/next/lib/utils/percent';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/next/components/ui/dialog';
import { useRuns } from '@/next/hooks/use-runs';
import { cn } from '@/next/lib/utils';
function MetricBadge({
metrics,
status,
total,
onClick,
className,
isLoading,
}: {
metrics: V1TaskRunMetrics;
status: V1TaskStatus;
total: number;
onClick: (status: V1TaskStatus) => void;
className: string;
isLoading: boolean;
}) {
const metric = metrics.find((m) => m.status === status);
const percentage = percent(metric?.count || 0, total);
return (
<RunsBadge
status={status}
count={metric?.count}
percentage={percentage}
className={className}
variant="detail"
onClick={() => onClick(status)}
isLoading={isLoading}
animated={false}
/>
);
}
export const RunsMetricsView = () => {
const {
metrics,
queueMetrics,
filters: { filters, setFilter },
} = useRuns();
const [isQueueMetricsOpen, setIsQueueMetricsOpen] = useState(false);
const total = metrics.data
.map((m) => m.count)
.reduce((acc, curr) => acc + curr, 0);
const handleMetricClick = (status?: V1TaskStatus) => {
if (status) {
// Toggle the filter - if it's already set to this status, clear it
const currentStatuses = filters.statuses || [];
if (currentStatuses.includes(status)) {
setFilter('statuses', undefined);
} else {
setFilter('statuses', [status]);
}
}
};
const isMetricActive = (status: V1TaskStatus) => {
return !(
filters.statuses &&
filters.statuses.length > 0 &&
!filters.statuses.includes(status)
);
};
return (
<div className="flex flex-row justify-between gap-6">
<dl className="flex flex-row justify-start gap-6">
<MetricBadge
metrics={metrics.data}
status={V1TaskStatus.COMPLETED}
total={total}
onClick={handleMetricClick}
className={cn(
'cursor-pointer text-sm px-2 py-1 w-fit',
!isMetricActive(V1TaskStatus.COMPLETED) && 'opacity-50',
)}
isLoading={metrics.isLoading}
/>
<MetricBadge
metrics={metrics.data}
status={V1TaskStatus.RUNNING}
total={total}
onClick={handleMetricClick}
className={cn(
'cursor-pointer text-sm px-2 py-1 w-fit',
!isMetricActive(V1TaskStatus.RUNNING) && 'opacity-50',
)}
isLoading={metrics.isLoading}
/>
<MetricBadge
metrics={metrics.data}
status={V1TaskStatus.FAILED}
total={total}
onClick={handleMetricClick}
className={cn(
'cursor-pointer text-sm px-2 py-1 w-fit',
!isMetricActive(V1TaskStatus.FAILED) && 'opacity-50',
)}
isLoading={metrics.isLoading}
/>
<MetricBadge
metrics={metrics.data}
status={V1TaskStatus.QUEUED}
total={total}
onClick={handleMetricClick}
className={cn(
'cursor-pointer rounded-sm font-normal text-sm px-2 py-1 w-fit',
!isMetricActive(V1TaskStatus.QUEUED) && 'opacity-50',
)}
isLoading={metrics.isLoading}
/>
</dl>
<RunsBadge
status="QueueMetrics"
className="cursor-pointer rounded-sm font-normal text-sm px-2 py-1 w-fit"
onClick={() => setIsQueueMetricsOpen(true)}
isLoading={metrics.isLoading}
>
Queue metrics
</RunsBadge>
<Dialog open={isQueueMetricsOpen} onOpenChange={setIsQueueMetricsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Current Queue Status</DialogTitle>
<DialogDescription>
Detailed information about queued tasks
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<pre className="text-sm text-muted-foreground">
{JSON.stringify(queueMetrics.data?.queues, null, 2)}
</pre>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
};
@@ -0,0 +1,35 @@
import { BulkActionDialog } from '@/next/components/ui/dialog/bulk-action-dialog';
interface BulkActionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
action: 'replay' | 'cancel';
isLoading: boolean;
onConfirm: () => void;
}
export function RunsBulkActionDialog({
open,
onOpenChange,
action,
isLoading,
onConfirm,
}: BulkActionDialogProps) {
const title = action === 'replay' ? 'Replay All Runs' : 'Cancel All Runs';
const description =
action === 'replay'
? 'Are you sure you want to replay all runs? This will create new runs for all tasks in the current view.'
: 'Are you sure you want to cancel all runs? This will stop all running and queued tasks in the current view.';
return (
<BulkActionDialog
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
confirmButtonText={title}
isLoading={isLoading}
onConfirm={onConfirm}
/>
);
}
@@ -0,0 +1,274 @@
'use client';
import { ColumnDef, Row } from '@tanstack/react-table';
import {
V1TaskSummary,
V1TaskStatus,
WorkflowRunOrderByField,
V1WorkflowType,
} from '@/lib/api';
import { Time } from '@/next/components/ui/time';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/next/components/ui/tooltip';
import { DataTableColumnHeader } from './data-table-column-header';
import { RunId } from '../run-id';
import { RunsBadge } from '../runs-badge';
import { Duration } from '@/next/components/ui/duration';
import { AdditionalMetadata } from '@/next/components/ui/additional-meta';
import { Link } from 'react-router-dom';
import { ROUTES } from '@/next/lib/routes';
import { useRuns } from '@/next/hooks/use-runs';
import { Checkbox } from '@/next/components/ui/checkbox';
import { Button } from '@/components/v1/ui/button';
import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
import { cn } from '@/next/lib/utils';
export const columns = (
rowClicked?: (row: V1TaskSummary) => void,
selectAll?: boolean,
): ColumnDef<V1TaskSummary>[] => [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={selectAll || table.getIsAllPageRowsSelected()}
onCheckedChange={(value: boolean) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
className="translate-y-[2px]"
disabled={selectAll}
/>
),
cell: ({ row }) => (
<div
className={cn(
`pl-${row.depth * 4}`,
'flex flex-row items-center justify-start gap-x-2 max-w-6 mr-2',
)}
>
<Checkbox
checked={selectAll || row.getIsSelected()}
onCheckedChange={(value: boolean) => row.toggleSelected(!!value)}
aria-label="Select row"
disabled={selectAll}
/>
{row.getCanExpand() && (
<Button
onClick={() => row.toggleExpanded()}
variant="ghost"
size="icon"
className="cursor-pointer px-2"
hoverText="Show tasks"
>
{row.getIsExpanded() ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
</Button>
)}
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'status',
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => {
const status = row.getValue('status') as V1TaskStatus;
return (
<div className="flex items-center justify-center h-full">
<RunsBadge variant="xs" status={status} />
</div>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'runId',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Run ID" />
),
cell: ({ row }) => {
const url =
row.original.type === V1WorkflowType.TASK
? undefined
: ROUTES.runs.detail(row.original.taskExternalId || '');
return (
<div className="flex items-center gap-2">
<RunId
taskRun={row.original}
onClick={rowClicked ? () => rowClicked(row.original) : undefined}
/>
{url && (
<Link
to={url}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</Link>
)}
</div>
);
},
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'workflowName',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Definition" />
),
cell: ({ row }) => <div>{row.getValue('workflowName')}</div>,
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Created"
orderBy={WorkflowRunOrderByField.CreatedAt}
/>
),
cell: ({ row }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Time date={row.getValue('createdAt')} variant="timeSince" />
</span>
</TooltipTrigger>
<TooltipContent className="bg-muted">
<Time
date={row.getValue('createdAt')}
variant="timestamp"
className="font-mono text-foreground"
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
),
enableSorting: false,
enableHiding: true,
},
{
accessorKey: 'startedAt',
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Started"
orderBy={WorkflowRunOrderByField.StartedAt}
/>
),
cell: ({ row }) => {
const startedAt = row.getValue('startedAt') as string | null;
if (!startedAt) {
return <span>-</span>;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Time date={startedAt} variant="timeSince" />
</span>
</TooltipTrigger>
<TooltipContent className="bg-muted">
<Time
date={startedAt}
variant="timestamp"
asChild
className="font-mono text-foreground"
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
enableSorting: false,
enableHiding: true,
},
{
accessorKey: 'duration',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Duration" />
),
cell: ({ row }) => {
const startedAt = row.getValue('startedAt') as string | null;
const finishedAt = row.original.finishedAt as string | null;
const status = row.getValue('status') as V1TaskStatus;
return (
<Duration
start={startedAt}
end={finishedAt}
status={status}
variant="compact"
/>
);
},
enableSorting: false,
enableHiding: true,
},
{
accessorKey: 'additionalMetadata',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Metadata" />
),
cell: ({ row }) => {
return <AdditionalMetadataCell row={row} />;
},
enableSorting: false,
enableHiding: true,
},
];
function AdditionalMetadataCell({ row }: { row: Row<V1TaskSummary> }) {
const {
filters: { setFilter, filters },
} = useRuns();
const metadata = row.original.additionalMetadata;
if (!metadata) {
return <span>-</span>;
}
return (
<AdditionalMetadata
metadata={metadata}
onClick={(click) => {
setFilter('additional_metadata', [
...(filters.additional_metadata || []),
`${click.key}:${click.value}`,
]);
}}
/>
);
}
@@ -0,0 +1,69 @@
'use client';
import { Column } from '@tanstack/react-table';
import { ArrowDown, ArrowUp, ChevronsUpDown, EyeOff } from 'lucide-react';
import { cn } from '@/next/lib/utils';
import { Button } from '@/next/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/next/components/ui/dropdown-menu';
import { WorkflowRunOrderByField } from '@/lib/api';
interface DataTableColumnHeaderProps<TData, TValue>
extends React.HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>;
title: string;
orderBy?: WorkflowRunOrderByField;
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 data-[state=open]:bg-accent"
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDown className="ml-2 h-4 w-4" />
) : column.getIsSorted() === 'asc' ? (
<ArrowUp className="ml-2 h-4 w-4" />
) : (
<ChevronsUpDown className="ml-2 h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUp className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDown className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Desc
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOff className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
Hide
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -0,0 +1,254 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import {
ColumnDef,
ColumnFiltersState,
VisibilityState,
RowSelectionState,
OnChangeFn,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
Row,
ExpandedState,
} from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/next/components/ui/table';
import { cn } from '@/next/lib/utils';
import { useNavigate } from 'react-router-dom';
import { ROUTES } from '@/next/lib/routes';
import { V1WorkflowType } from '@/lib/api';
const styles = {
status: 'p-0 w-[40px]',
runId: 'border-r border-border',
};
interface IDGetter {
metadata: {
id: string;
};
isExpandable?: boolean;
}
interface DataTableProps<TData extends IDGetter, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
emptyState?: React.ReactNode;
isLoading?: boolean;
selectedTaskId?: string;
onRowClick?: (row: TData) => void;
onSelectionChange?: (selectedRows: TData[]) => void;
rowSelection?: RowSelectionState;
setRowSelection?: OnChangeFn<RowSelectionState>;
selectAll?: boolean;
getSubRows?: (originalRow: TData, index: number) => TData[];
}
export function DataTable<TData extends IDGetter, TValue>({
columns,
data,
emptyState,
isLoading,
selectedTaskId,
onRowClick = () => {},
onSelectionChange,
rowSelection = {},
setRowSelection,
selectAll = false,
getSubRows,
}: DataTableProps<TData, TValue>) {
const navigate = useNavigate();
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [expanded, setExpanded] = useState<ExpandedState>({});
const memoizedRowSelection = useMemo(() => {
if (selectAll) {
return data.reduce((acc, _, index) => ({ ...acc, [index]: true }), {});
}
return rowSelection;
}, [selectAll, data, rowSelection]);
const table = useReactTable({
data,
columns,
state: {
columnFilters,
columnVisibility,
rowSelection: memoizedRowSelection,
expanded,
},
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getRowId: (row) => {
const typedRow = row as { taskExternalId?: string; id?: string };
return typedRow.taskExternalId || typedRow.id || String(Math.random());
},
getSubRows,
getRowCanExpand: (row) => row.subRows.length > 0,
onExpandedChange: setExpanded,
});
// Notify parent component of selection changes
useEffect(() => {
if (onSelectionChange) {
const selectedRows = table
.getSelectedRowModel()
.rows.map((row) => row.original);
onSelectionChange(selectedRows);
}
}, [onSelectionChange, table]);
const getTableRow = (
row: Row<TData>,
isSelected: boolean,
isTaskSelected: boolean,
handleClick: (e: React.MouseEvent) => void,
handleDoubleClick: () => void,
) => {
return (
<TableRow
key={row.id}
data-state={isSelected || isTaskSelected ? 'selected' : undefined}
className={cn(
row.original.isExpandable && 'cursor-pointer hover:bg-muted',
'group cursor-pointer',
)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn(styles[cell.column.id as keyof typeof styles])}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
};
const handleClick = useCallback(
(row: Row<TData>, e: React.MouseEvent, isSelected: boolean) => {
// Prevent row click if clicking on a button or link
if ((e.target as HTMLElement).closest('button, a')) {
return;
}
// If Cmd/Ctrl is held, toggle selection instead of triggering row click
if (e.metaKey || e.ctrlKey) {
row.toggleSelected(!isSelected);
return;
}
onRowClick(row.original);
},
[onRowClick],
);
const handleDoubleClick = useCallback(
(row: Row<TData>) => {
// TODO: Fix type
const task = row.original as any;
if (task.type !== V1WorkflowType.TASK) {
navigate(ROUTES.runs.detail(task.taskExternalId || ''));
}
},
[navigate],
);
if (isLoading) {
return (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
Loading...
</TableCell>
</TableRow>
);
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={cn(styles[header.id as keyof typeof styles])}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{!table.getRowModel().rows?.length && (
<TableRow>
<TableCell
colSpan={table.getHeaderGroups()[0].headers.length}
className="h-48 text-center py-8"
>
{emptyState || 'No results found.'}
</TableCell>
</TableRow>
)}
{table.getRowModel().rows.map((row) => {
const isSelected = row.getIsSelected();
const isTaskSelected =
selectedTaskId === (row.original as any).taskExternalId;
return (
<>
{getTableRow(
row,
isSelected,
isTaskSelected,
(e: React.MouseEvent) => handleClick(row, e, isSelected),
() => handleDoubleClick(row),
)}
{row.getIsExpanded() &&
row.subRows.map((r) =>
getTableRow(
r,
isSelected,
isTaskSelected,
(e: React.MouseEvent) => handleClick(r, e, isSelected),
() => handleDoubleClick(r),
),
)}
</>
);
})}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,340 @@
import { useRuns, RunsFilters } from '@/next/hooks/use-runs';
import { useEffect, useMemo, useState, useCallback } from 'react';
import { DataTable } from './data-table';
import { columns } from './columns';
import {
Pagination,
PageSizeSelector,
PageSelector,
} from '@/next/components/ui/pagination';
import {
FilterGroup,
FilterSelect,
FilterTaskSelect,
FilterKeyValue,
ClearFiltersButton,
} from '@/next/components/ui/filters/filters';
import { V1TaskStatus, V1TaskSummary } from '@/lib/api';
import { DocsButton } from '@/next/components/ui/docs-button';
import docs from '@/next/lib/docs';
import { RowSelectionState, OnChangeFn } from '@tanstack/react-table';
import { MdOutlineReplay, MdOutlineCancel } from 'react-icons/md';
import { Button } from '@/next/components/ui/button';
import { RunsBulkActionDialog } from './bulk-action-dialog';
import { Plus } from 'lucide-react';
interface RunsTableProps {
onRowClick?: (row: V1TaskSummary) => void;
selectedTaskId?: string;
onSelectionChange?: (selectedRows: V1TaskSummary[]) => void;
onTriggerRunClick?: () => void;
}
export function RunsTable({
onRowClick,
selectedTaskId,
onSelectionChange,
onTriggerRunClick,
}: RunsTableProps) {
const {
data: runs,
count,
timeRange: { pause, isPaused },
isLoading,
filters: { filters, clearAllFilters },
hasFilters,
cancel,
replay,
} = useRuns();
const [selectAll, setSelectAll] = useState(false);
const [showBulkActionDialog, setShowBulkActionDialog] = useState<
'replay' | 'cancel' | null
>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [selectedTasks, setSelectedTasks] = useState<
Map<string, V1TaskSummary>
>(new Map());
useEffect(() => {
if (Object.keys(rowSelection).length > 0 && !isPaused) {
pause();
}
}, [pause, rowSelection, isPaused]);
const selectedRuns = useMemo(() => {
return Array.from(selectedTasks.values());
}, [selectedTasks]);
const canCancel = useMemo(() => {
return selectedRuns.some(
(t) =>
t.status === V1TaskStatus.RUNNING || t.status === V1TaskStatus.QUEUED,
);
}, [selectedRuns]);
const canReplay = useMemo(() => {
return selectedRuns.length > 0;
}, [selectedRuns]);
const additionalMetaOpts = useMemo(() => {
if (!runs || runs.length === 0) {
return [];
}
const allKeys = new Set<string>();
runs.forEach((run) => {
if (run.additionalMetadata) {
Object.keys(run.additionalMetadata).forEach((key) => allKeys.add(key));
}
});
return Array.from(allKeys).map((key) => ({
label: key,
value: key,
}));
}, [runs]);
const numSelectedRows = useMemo(() => {
return Object.keys(rowSelection).length;
}, [rowSelection]);
const handleSelectionChange: OnChangeFn<RowSelectionState> = (
updaterOrValue,
) => {
const newSelection =
typeof updaterOrValue === 'function'
? updaterOrValue(rowSelection)
: updaterOrValue;
setRowSelection(newSelection);
// Update the selected tasks map
const newSelectedTasks = new Map();
if (runs) {
Object.keys(newSelection).forEach((taskId) => {
const task = runs.find((run) => run.taskExternalId === taskId);
if (task) {
newSelectedTasks.set(taskId, task);
}
});
}
setSelectedTasks(newSelectedTasks);
};
const clearSelection = useCallback(() => {
setSelectAll(false);
setRowSelection({});
setSelectedTasks(new Map());
}, [setSelectAll, setRowSelection, setSelectedTasks]);
useEffect(() => {
clearSelection();
}, [filters, clearSelection]);
return (
<>
<FilterGroup>
<FilterSelect<RunsFilters, V1TaskStatus[]>
name="statuses"
value={filters.statuses}
placeholder="Status"
multi
options={[
{ label: 'Running', value: V1TaskStatus.RUNNING },
{ label: 'Completed', value: V1TaskStatus.COMPLETED },
{ label: 'Failed', value: V1TaskStatus.FAILED },
{ label: 'Cancelled', value: V1TaskStatus.CANCELLED },
{ label: 'Queued', value: V1TaskStatus.QUEUED },
]}
/>
<FilterTaskSelect<RunsFilters>
name="workflow_ids"
placeholder="Name"
multi
/>
<FilterSelect<RunsFilters, boolean>
name="is_root_task"
value={filters.is_root_task}
placeholder="Only Root Tasks"
options={[
{ label: 'Yes', value: true },
{ label: 'No', value: false },
]}
/>
<FilterTaskSelect<RunsFilters>
name="workflow_ids"
placeholder="Task Name"
multi
/>
<FilterKeyValue<RunsFilters>
name="additional_metadata"
placeholder="Metadata"
options={additionalMetaOpts}
/>
<ClearFiltersButton />
</FilterGroup>
<div className="flex items-center justify-between">
<div className="flex-1 text-sm text-muted-foreground">
{numSelectedRows > 0 || selectAll ? (
<>
<span className="text-muted-foreground">
{selectAll
? count.toLocaleString()
: numSelectedRows.toLocaleString()}{' '}
of {count.toLocaleString()} runs selected
</span>
</>
) : (
<span className="text-muted-foreground">
{count.toLocaleString()} runs
</span>
)}
{count > 0 && !selectAll && (
<Button
variant="ghost"
size="sm"
className="ml-2 h-6 px-2"
onClick={() => setSelectAll(true)}
>
Select All
</Button>
)}
{(numSelectedRows > 0 || selectAll) && (
<Button
variant="ghost"
size="sm"
className="ml-2 h-6 px-2"
onClick={clearSelection}
>
Clear Selection
</Button>
)}
</div>
{!selectAll ? (
<div className="flex gap-2">
<Button
tooltip={
numSelectedRows == 0
? 'No runs selected'
: canReplay
? 'Replay the selected runs'
: 'Cannot replay the selected runs'
}
variant="outline"
size="sm"
disabled={!canReplay || replay.isPending}
onClick={async () => replay.mutateAsync({ tasks: selectedRuns })}
>
<MdOutlineReplay className="h-4 w-4" />
Replay
</Button>
<Button
tooltip={
numSelectedRows == 0
? 'No runs selected'
: canCancel
? 'Cancel the selected runs'
: 'Cannot cancel the selected runs because they are not running or queued'
}
variant="outline"
size="sm"
disabled={!canCancel || cancel.isPending}
onClick={async () => cancel.mutateAsync({ tasks: selectedRuns })}
>
<MdOutlineCancel className="h-4 w-4" />
Cancel
</Button>
</div>
) : (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={replay.isPending}
onClick={() => setShowBulkActionDialog('replay')}
>
<MdOutlineReplay className="h-4 w-4" />
Replay All
</Button>
<Button
variant="outline"
size="sm"
disabled={cancel.isPending}
onClick={() => setShowBulkActionDialog('cancel')}
>
<MdOutlineCancel className="h-4 w-4" />
Cancel All
</Button>
</div>
)}
</div>
<DataTable
columns={columns(onRowClick, selectAll)}
data={runs || []}
emptyState={
<div className="flex flex-col items-center justify-center gap-4 py-8">
<p className="text-md">No runs found.</p>
<p className="text-sm text-muted-foreground">
Trigger a new run to get started.
</p>
<div className="flex flex-col gap-2">
<Button size="sm" onClick={onTriggerRunClick}>
<Plus className="h-4 w-4 mr-2" />
Trigger Run
</Button>
{hasFilters && (
<Button
size="sm"
variant="outline"
onClick={() => clearAllFilters()}
>
Clear Filters
</Button>
)}
<DocsButton
doc={docs.home.running_tasks}
titleOverride="Running Tasks"
/>
</div>
</div>
}
isLoading={isLoading}
selectedTaskId={selectedTaskId}
onRowClick={onRowClick}
onSelectionChange={onSelectionChange}
rowSelection={rowSelection}
setRowSelection={handleSelectionChange}
selectAll={selectAll}
getSubRows={(row) => row.children || []}
/>
<Pagination className="mt-4 justify-between flex flex-row">
<PageSizeSelector />
<PageSelector variant="dropdown" />
</Pagination>
<RunsBulkActionDialog
open={!!showBulkActionDialog}
onOpenChange={(open) =>
setShowBulkActionDialog(open ? showBulkActionDialog : null)
}
action={showBulkActionDialog || 'replay'}
isLoading={
showBulkActionDialog === 'replay'
? replay.isPending
: cancel.isPending
}
onConfirm={async () => {
if (showBulkActionDialog === 'replay') {
await replay.mutateAsync({ bulk: true });
} else {
await cancel.mutateAsync({ bulk: true });
}
setShowBulkActionDialog(null);
}}
/>
</>
);
}
@@ -0,0 +1,559 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/next/components/ui/dialog';
import { Button } from '@/next/components/ui/button';
import { Input } from '@/next/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/next/components/ui/select';
import CronPrettifier from 'cronstrue';
import { TimePicker } from '@/next/components/ui/time-picker';
import useDefinitions from '@/next/hooks/use-definitions';
import { useNavigate } from 'react-router-dom';
import { useState, useMemo, useEffect } from 'react';
import {
V1WorkflowRunDetails,
Workflow,
ScheduledWorkflows,
CronWorkflows,
} from '@/lib/api';
import { RunsProvider, useRuns } from '@/next/hooks/use-runs';
import { CronsProvider, useCrons } from '@/next/hooks/use-crons';
import { SchedulesProvider, useSchedules } from '@/next/hooks/use-schedules';
import { CodeEditor } from '@/components/v1/ui/code-editor';
import { ROUTES } from '@/next/lib/routes';
import { RunDetailProvider, useRunDetail } from '@/next/hooks/use-run-detail';
import { getFriendlyWorkflowRunId } from '@/next/components/runs/run-id';
import { FaCodeBranch } from 'react-icons/fa';
type TimingOption = 'now' | 'schedule' | 'cron';
type TriggerRunCapability =
| 'workflow'
| 'fromRecent'
| 'input'
| 'additionalMeta'
| 'timing';
type TriggerRunModalProps = {
show: boolean;
onClose: () => void;
defaultTimingOption?: TimingOption;
defaultInput?: string;
defaultAddlMeta?: string;
defaultWorkflowId?: string;
defaultRunId?: string;
onRun?: (
run: V1WorkflowRunDetails | ScheduledWorkflows | CronWorkflows,
) => void;
disabledCapabilities?: TriggerRunCapability[];
};
export function TriggerRunModal(props: TriggerRunModalProps) {
return (
<CronsProvider>
<SchedulesProvider>
<RunsProvider
initialPagination={{
initialPageSize: 5,
}}
initialFilters={{
workflow_ids: props.defaultWorkflowId
? [props.defaultWorkflowId]
: undefined,
}}
>
<TriggerRunModalContent {...props} />
</RunsProvider>
</SchedulesProvider>
</CronsProvider>
);
}
function WithPreviousInput({
setInput,
setAddlMeta,
}: {
setInput: (input: string) => void;
setAddlMeta: (addlMeta: string) => void;
}) {
const { data: selectedRunDetails } = useRunDetail();
useEffect(() => {
if (selectedRunDetails?.run) {
setInput(
JSON.stringify((selectedRunDetails.run.input as any).input, null, 2),
);
setAddlMeta(
JSON.stringify(selectedRunDetails.run.additionalMetadata, null, 2),
);
}
}, [selectedRunDetails, setAddlMeta, setInput]);
return null;
}
function TriggerRunModalContent({
show,
onClose,
defaultTimingOption = 'now',
defaultInput = '{}',
defaultAddlMeta = '{}',
defaultWorkflowId,
defaultRunId,
disabledCapabilities = [],
onRun,
}: TriggerRunModalProps) {
const navigate = useNavigate();
const { data: workflows } = useDefinitions();
const [selectedWorkflowId, setSelectedWorkflowId] = useState<
string | undefined
>(defaultWorkflowId);
const { data: recentRuns, triggerNow } = useRuns();
const [selectedRunId, setSelectedRunId] = useState<string>(
defaultRunId || '',
);
const { create: createCron } = useCrons();
const { create: createSchedule } = useSchedules();
const [input, setInput] = useState<string>(defaultInput);
const [addlMeta, setAddlMeta] = useState<string>(defaultAddlMeta);
const [errors, setErrors] = useState<string[]>([]);
const [timingOption, setTimingOption] =
useState<TimingOption>(defaultTimingOption);
const [scheduleTime, setScheduleTime] = useState<Date | undefined>(
new Date(),
);
const [cronExpression, setCronExpression] = useState<string>('* * * * *');
const [cronName, setCronName] = useState<string>('');
const resetForm = () => {
setInput(defaultInput);
setAddlMeta(defaultAddlMeta);
setErrors([]);
setTimingOption(defaultTimingOption);
setScheduleTime(new Date());
setCronExpression('* * * * *');
setCronName('');
setSelectedWorkflowId(defaultWorkflowId);
setSelectedRunId('');
};
const cronPretty = useMemo(() => {
try {
return {
pretty: CronPrettifier.toString(cronExpression).toLowerCase(),
};
} catch (e) {
console.error(e);
return { error: e as string };
}
}, [cronExpression]);
const selectedWorkflow = useMemo(() => {
if (!workflows) {
return undefined;
}
return workflows.find(
(w: Workflow) => w.metadata.id === selectedWorkflowId,
);
}, [workflows, selectedWorkflowId]);
const handleSubmit = () => {
if (!selectedWorkflow) {
setErrors(['No workflow selected.']);
return;
}
const inputObj = JSON.parse(input);
const addlMetaObj = JSON.parse(addlMeta);
if (timingOption === 'now') {
triggerNow.mutate(
{
workflowName: selectedWorkflow.name,
input: inputObj,
additionalMetadata: addlMetaObj,
},
{
onSuccess: (workflowRun) => {
if (!workflowRun) {
return;
}
onClose();
if (onRun) {
onRun(workflowRun);
} else {
navigate(ROUTES.runs.detail(workflowRun.run.metadata.id));
}
},
onError: (error) => {
setErrors([error.message]);
},
},
);
} else if (timingOption === 'schedule') {
if (!scheduleTime) {
setErrors(['Please select a date and time for scheduling.']);
return;
}
createSchedule.mutate(
{
workflowName: selectedWorkflow.name,
data: {
input: inputObj,
additionalMetadata: addlMetaObj,
triggerAt: new Date(
scheduleTime.getTime() - scheduleTime.getTimezoneOffset() * 60000,
).toISOString(),
},
},
{
onSuccess: (schedule) => {
onClose();
if (onRun) {
onRun(schedule);
} else {
navigate(ROUTES.scheduled.list);
}
},
onError: (error: any) => {
if (error?.response?.data?.errors) {
setErrors(error.response.data.errors);
} else {
setErrors([error.message || 'Failed to schedule run']);
}
},
},
);
} else if (timingOption === 'cron') {
if (!cronExpression) {
setErrors(['Please enter a valid cron expression.']);
return;
}
if (!cronName) {
setErrors(['Please enter a name for the cron job.']);
return;
}
createCron.mutate(
{
workflowId: selectedWorkflow.name,
data: {
input: inputObj,
additionalMetadata: addlMetaObj,
cronName: cronName,
cronExpression: cronExpression,
},
},
{
onSuccess: (cron) => {
onClose();
if (onRun) {
onRun(cron);
} else {
navigate(ROUTES.crons.list);
}
},
onError: (error: any) => {
if (error?.response?.data?.errors) {
setErrors(error.response.data.errors);
} else {
setErrors([error.message || 'Failed to create cron job']);
}
},
},
);
}
};
return (
<Dialog
open={show}
onOpenChange={(open) => {
if (!open) {
resetForm();
onClose();
}
}}
>
<DialogContent className="sm:max-w-[625px]">
<DialogHeader>
<DialogTitle>Trigger Run</DialogTitle>
<DialogDescription>
Trigger a workflow to run now, at a scheduled time, or on a cron
schedule.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{selectedRunId && !disabledCapabilities.includes('fromRecent') && (
<RunDetailProvider runId={selectedRunId}>
<WithPreviousInput
setInput={setInput}
setAddlMeta={setAddlMeta}
/>
</RunDetailProvider>
)}
{!disabledCapabilities.includes('workflow') && (
<div>
<label className="text-sm font-medium">Workflow</label>
<Select
value={selectedWorkflowId}
onValueChange={(value) => {
setSelectedWorkflowId(value);
setSelectedRunId('');
}}
>
<SelectTrigger className="w-full mt-1">
<SelectValue placeholder="Select a workflow" />
</SelectTrigger>
<SelectContent>
<SelectItem value="placeholder">Select a workflow</SelectItem>
{workflows?.map((workflow: Workflow) => (
<SelectItem
key={workflow.metadata.id}
value={workflow.metadata.id}
>
{workflow.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{!disabledCapabilities.includes('fromRecent') && (
<div>
<label className="text-sm font-medium flex items-center gap-2">
<FaCodeBranch className="text-muted-foreground" size={16} />
From Recent Run
</label>
<select
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 disabled:opacity-50 disabled:cursor-not-allowed"
value={selectedRunId}
disabled={!selectedWorkflowId}
onChange={(e) => {
const runId = e.target.value;
setSelectedRunId(runId);
}}
>
<option value="">Select a recent run</option>
{recentRuns
?.filter((run) => run.workflowId === selectedWorkflowId)
.map((run) => (
<option key={run.metadata.id} value={run.metadata.id}>
{getFriendlyWorkflowRunId(run)}
</option>
))}
</select>
</div>
)}
{!disabledCapabilities.includes('input') && (
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Input</label>
<Button
variant="ghost"
size="sm"
onClick={() => {
setInput('{}');
setSelectedRunId('');
}}
className="h-8 px-2"
>
Clear
</Button>
</div>
<CodeEditor
language="json"
className="mt-1"
height="180px"
code={input}
setCode={(code) => code && setInput(code)}
/>
</div>
)}
{!disabledCapabilities.includes('additionalMeta') && (
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium">
Additional Metadata
</label>
<Button
variant="ghost"
size="sm"
onClick={() => setAddlMeta('{}')}
className="h-8 px-2"
>
Clear
</Button>
</div>
<CodeEditor
language="json"
className="mt-1"
height="90px"
code={addlMeta}
setCode={(code) => code && setAddlMeta(code)}
/>
</div>
)}
{!disabledCapabilities.includes('timing') && (
<div>
<label className="text-sm font-medium">Timing</label>
<Tabs
value={timingOption}
onValueChange={(value: string) =>
setTimingOption(value as TimingOption)
}
>
<TabsList>
<TabsTrigger value="now">Now</TabsTrigger>
<TabsTrigger value="schedule">Schedule</TabsTrigger>
<TabsTrigger value="cron">Cron</TabsTrigger>
</TabsList>
<TabsContent value="now" />
<TabsContent value="schedule">
<div className="mt-4">
<div className="font-bold mb-2">Select Date and Time</div>
<div className="flex gap-2">
<TimePicker
date={scheduleTime}
setDate={setScheduleTime}
timezone="Local"
/>
<Button
variant="outline"
size="sm"
onClick={() => setScheduleTime(new Date())}
>
Now
</Button>
</div>
<div className="flex gap-2 mt-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const newTime = new Date(scheduleTime || new Date());
newTime.setSeconds(newTime.getSeconds() + 15);
setScheduleTime(newTime);
}}
>
+15s
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const newTime = new Date(scheduleTime || new Date());
newTime.setMinutes(newTime.getMinutes() + 1);
setScheduleTime(newTime);
}}
>
+1m
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const newTime = new Date(scheduleTime || new Date());
newTime.setMinutes(newTime.getMinutes() + 5);
setScheduleTime(newTime);
}}
>
+5m
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const newTime = new Date(scheduleTime || new Date());
newTime.setMinutes(newTime.getMinutes() + 15);
setScheduleTime(newTime);
}}
>
+15m
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const newTime = new Date(scheduleTime || new Date());
newTime.setMinutes(newTime.getMinutes() + 60);
setScheduleTime(newTime);
}}
>
+60m
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="cron">
<div className="mt-4">
<div className="font-bold mb-2">Cron Expression</div>
<Input
type="text"
value={cronName}
onChange={(e) => setCronName(e.target.value)}
placeholder="e.g., cron-name"
className="w-full mb-2"
/>
<div className="font-bold mb-2">Cron Expression</div>
<Input
type="text"
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="e.g., 0 0 * * *"
className="w-full"
/>
<div className="text-sm text-muted-foreground mt-1">
{cronPretty.error || `(runs ${cronPretty.pretty} UTC)`}
</div>
</div>
</TabsContent>
</Tabs>
</div>
)}
{errors.length > 0 && (
<div className="text-sm text-destructive">
{errors.map((error, i) => (
<p key={i}>{error}</p>
))}
</div>
)}
<div className="flex justify-end">
<Button
onClick={handleSubmit}
loading={
triggerNow.isPending ||
createSchedule.isPending ||
createCron.isPending
}
>
{timingOption === 'now'
? 'Run Now'
: timingOption === 'schedule'
? 'Schedule Run'
: 'Create Cron'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,65 @@
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
};
const ThemeProviderContext = createContext<ThemeProviderState | null>(null);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'vite-ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem(storageKey);
return (stored as Theme) || defaultTheme;
});
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(theme === 'system' ? systemTheme : theme);
}, [theme]);
const setThemeAndLocal = (newTheme: Theme) => {
localStorage.setItem(storageKey, newTheme);
setTheme(newTheme);
};
const value = {
theme,
setTheme: setThemeAndLocal,
toggleTheme: () => setThemeAndLocal(theme === 'dark' ? 'light' : 'dark'),
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
@@ -0,0 +1,59 @@
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from '@radix-ui/react-icons';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> & {
hideChevron?: boolean;
}
>(({ className, children, hideChevron, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
{!hideChevron && (
<ChevronDownIcon className="h-4 w-4 shrink-0 text-gray-700 dark:text-gray-300 transition-transform duration-200" />
)}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
@@ -0,0 +1,93 @@
import { Badge } from '@/next/components/ui/badge';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/next/components/ui/popover';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/next/components/ui/tooltip';
const MAX_METADATA_LENGTH = 2;
interface AdditionalMetadataClick {
key: string;
value: any;
}
interface AdditionalMetadataProps {
metadata?: object;
onClick?: (click: AdditionalMetadataClick) => void;
}
export function AdditionalMetadata({
metadata,
onClick,
}: AdditionalMetadataProps) {
const metadataEntries = Object.entries(metadata || {});
const visibleEntries = metadataEntries.slice(0, MAX_METADATA_LENGTH);
const hiddenEntries = metadataEntries.slice(MAX_METADATA_LENGTH);
return (
<div className="flex flex-row gap-2 items-center justify-start">
{visibleEntries.map(([key, value]) => (
<TooltipProvider key={key}>
<Tooltip>
<TooltipTrigger>
<Badge
className="mr-2 truncate cursor-default font-normal cursor-pointer"
variant="secondary"
onClick={() => onClick?.({ key, value })}
>
{`${key}: ${getValueString(value)}`}
</Badge>
</TooltipTrigger>
<TooltipContent>
{key}: {JSON.stringify(value)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
{hiddenEntries.length > 0 && (
<Popover>
<PopoverTrigger>
<Badge className="cursor-pointer font-normal" variant="secondary">
+ {hiddenEntries.length} more
</Badge>
</PopoverTrigger>
<PopoverContent
className="min-w-fit p-3 m-0 bg-background rounded"
align="end"
>
<div className="flex flex-col gap-2 p-0">
{metadataEntries.map(([key, value]) => (
<Badge
className="mr-2 truncate font-normal text-sm cursor-pointer"
title={`${key}:${value}`}
variant="secondary"
key={key}
onClick={() => onClick?.({ key, value })}
>
{`${key}: ${value}`}
</Badge>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
}
const getValueString = (value: any) => {
const res = JSON.stringify(value).replace(/"/g, '').substring(0, 10);
if (value && value?.length > 10) {
return `${res}...`;
}
return res;
};
@@ -0,0 +1,139 @@
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/next/lib/utils';
import { buttonVariants } from '@/next/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
@@ -0,0 +1,61 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/next/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
warning:
'border-orange-500/50 bg-orange-50 text-orange-700 dark:border-orange-400/50 dark:bg-orange-950/50 dark:text-orange-300 [&>svg]:text-orange-600',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,48 @@
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/next/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
@@ -0,0 +1,80 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/next/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/next/components/ui/tooltip';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground shadow hover:opacity-80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
successful:
'border-transparent rounded-sm px-1 font-normal text-green-800 dark:text-green-300 bg-green-500/20 ring-green-500/30',
destructive:
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
xs: 'w-3 h-3 p-0 rounded-sm border-transparent',
small: 'w-3 h-3 p-0 rounded-sm border-transparent',
detail: 'text-foreground',
},
animated: {
true: 'animate-pulse',
false: '',
},
},
defaultVariants: {
variant: 'default',
animated: false,
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {
tooltipContent?: React.ReactNode;
}
function Badge({
className,
variant,
animated,
tooltipContent,
...props
}: BadgeProps) {
// If it's the small variant and tooltip content is provided
if (variant === 'xs' && tooltipContent) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(badgeVariants({ variant, animated }), className)}
{...props}
/>
</TooltipTrigger>
<TooltipContent>{tooltipContent}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<div
className={cn(badgeVariants({ variant, animated }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
@@ -0,0 +1,124 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/next/lib/utils';
import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons';
import { Link as RouterLink } from 'react-router-dom';
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className,
)}
{...props}
/>
));
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
));
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
to?: string;
}
>(({ asChild, className, href, to, ...props }, ref) => {
if (asChild) {
return (
<Slot
ref={ref}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
);
}
return (
<RouterLink
ref={ref}
to={to || href || ''}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn('font-normal text-foreground', className)}
{...props}
/>
));
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:w-3.5 [&>svg]:h-3.5', className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
role="presentation"
aria-hidden="true"
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
@@ -0,0 +1,106 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/next/lib/utils/index.ts';
import { FaSpinner } from 'react-icons/fa';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from './tooltip';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 relative overflow-hidden',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
tooltip?: React.ReactNode;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
asChild = false,
loading = false,
tooltip,
disabled,
...props
},
ref,
) => {
const Comp = asChild ? Slot : 'button';
const isDisabled = loading || disabled;
const buttonContent = (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={isDisabled}
{...props}
>
{loading && (
<div className="absolute inset-0 flex items-center justify-center">
<FaSpinner className="animate-spin h-5 w-5 text-secondary" />
</div>
)}
{props.children}
</Comp>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{isDisabled ? (
<div className="inline-flex">{buttonContent}</div>
) : (
buttonContent
)}
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return buttonContent;
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
@@ -0,0 +1,70 @@
import * as React from 'react';
import { ChevronLeftIcon, ChevronRightIcon } from '@radix-ui/react-icons';
import { DayPicker } from 'react-day-picker';
import { cn } from '@/next/lib/utils';
import { buttonVariants } from '@/next/components/ui/button';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
: '[&:has([aria-selected])]:rounded-md',
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal aria-selected:opacity-100',
),
day_range_start: 'day-range-start',
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: () => <ChevronLeftIcon className="h-4 w-4" />,
IconRight: () => <ChevronRightIcon className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = 'Calendar';
export { Calendar };
@@ -0,0 +1,88 @@
import * as React from 'react';
import { cn } from '@/next/lib/utils';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'default' | 'borderless';
}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, variant = 'default', ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl bg-card text-card-foreground shadow',
variant === 'default' && 'border',
variant === 'borderless' && 'p-0',
className,
)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};
@@ -0,0 +1,363 @@
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from '@/next/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />');
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children'];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = 'Chart';
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== 'dot';
return (
<div
ref={ref}
className={cn(
'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = 'ChartTooltip';
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground',
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = 'ChartLegend';
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
@@ -0,0 +1,455 @@
import { useState, useMemo, useRef } from 'react';
import {
CartesianGrid,
XAxis,
YAxis,
ReferenceArea,
ResponsiveContainer,
Bar,
BarChart,
LineChart,
Line,
Area,
AreaChart,
} from 'recharts';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/next/components/ui/chart';
import { cn } from '@/next/lib/utils';
import { capitalize } from '@/next/lib/utils/capitalize';
export type DataPoint<T extends string> = Record<T, number> & {
date: string;
};
const getNextActiveLabel = (activeLabel: string, data: DataPoint<string>[]) => {
const currentIndex = data.findIndex((d) => d.date === activeLabel);
if (currentIndex === -1) {
return null;
}
// if we're at the end of the data, determine the time between the last two data points and add that to the last date
if (currentIndex === data.length - 1) {
const lastDate = new Date(data[currentIndex].date);
const secondLastDate = new Date(data[currentIndex - 1].date);
const diff = lastDate.getTime() - secondLastDate.getTime();
return new Date(lastDate.getTime() + diff).toISOString();
}
return data[currentIndex + 1]?.date || activeLabel;
};
const getPrevActiveLabel = (activeLabel: string, data: DataPoint<string>[]) => {
const currentIndex = data.findIndex((d) => d.date === activeLabel);
if (currentIndex === -1) {
return activeLabel;
}
// if we're at the start of the data, determine the time between the first two data points and subtract that from the first date
if (currentIndex === 0) {
const firstDate = new Date(data[currentIndex].date);
const secondDate = new Date(data[currentIndex + 1].date);
const diff = secondDate.getTime() - firstDate.getTime();
return new Date(firstDate.getTime() - diff).toISOString();
}
return data[currentIndex - 1]?.date || activeLabel;
};
type ZoomableChartProps<T extends string> = {
data: DataPoint<T>[];
colors?: Record<string, string>;
zoom?: (startTime: string, endTime: string) => void;
showYAxis?: boolean;
kind: 'bar' | 'line' | 'area';
className?: string;
};
export function ZoomableChart<T extends string>({
data,
colors,
zoom,
showYAxis = true,
kind = 'bar',
className,
}: ZoomableChartProps<T>) {
const [refAreaLeft, setRefAreaLeft] = useState<string | null>(null);
const [refAreaRight, setRefAreaRight] = useState<string | null>(null);
const [actualRefAreaLeft, setActualRefAreaLeft] = useState<string | null>(
null,
);
const [actualRefAreaRight, setActualRefAreaRight] = useState<string | null>(
null,
);
const [isSelecting, setIsSelecting] = useState(false);
const chartRef = useRef<HTMLDivElement>(null);
const chartConfig = useMemo<ChartConfig>(() => {
const keys = Object.keys(data[0] || {}).filter((key) => key !== 'date');
return keys.reduce<ChartConfig>((acc, key, index) => {
let color = `hsl(${(index * 360) / keys.length}, 70%, 50%)`;
if (colors && colors[key]) {
color = colors[key];
}
if (index < 5) {
color = `hsl(var(--chart-${index + 1}))`;
}
acc[key] = {
label: capitalize(key),
color: colors?.[key] || color,
};
return acc;
}, {});
}, [data, colors]);
const handleMouseDown = (e: any) => {
if (e.activeLabel) {
setRefAreaLeft(e.activeLabel);
setActualRefAreaLeft(getPrevActiveLabel(e.activeLabel, data));
setIsSelecting(true);
}
};
const handleMouseMove = (e: any) => {
if (isSelecting && e.activeLabel) {
setRefAreaRight(e.activeLabel);
setActualRefAreaRight(getNextActiveLabel(e.activeLabel, data));
}
};
const handleMouseUp = () => {
if (actualRefAreaLeft && actualRefAreaRight) {
const [left, right] = [actualRefAreaLeft, actualRefAreaRight].sort();
zoom?.(left, right);
}
setRefAreaLeft(null);
setActualRefAreaLeft(null);
setRefAreaRight(null);
setActualRefAreaRight(null);
setIsSelecting(false);
};
const minDate = new Date(
Math.min(...data.map((d) => new Date(d.date).getTime())),
);
const maxDate = new Date(
Math.max(...data.map((d) => new Date(d.date).getTime())),
);
const formatXAxis = (tickItem: string) => {
const date = new Date(tickItem);
const timeDiff = maxDate.getTime() - minDate.getTime();
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
if (timeDiff > sevenDays) {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
} else if (timeDiff > oneDay) {
return `${date.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
} else {
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
};
// remove date from dataKeys
const dataKeys = Object.keys(data[0] || {}).filter((key) => key !== 'date');
return (
<ChartContainer
config={chartConfig}
className={cn('w-full h-[200px] min-h-[200px]', className)}
>
<div className="h-full" ref={chartRef} style={{ touchAction: 'none' }}>
{getChildChart(kind, {
data,
showYAxis,
formatXAxis,
handleMouseDown,
handleMouseMove,
handleMouseUp,
refAreaLeft,
refAreaRight,
chartConfig,
dataKeys,
})}
</div>
</ChartContainer>
);
}
function getChildChart<T extends string>(
kind: 'bar' | 'line' | 'area',
props: ChildChartProps<T>,
) {
switch (kind) {
case 'bar':
return <ChildBarChart {...props} />;
case 'line':
return <ChildLineChart {...props} />;
case 'area':
return <ChildAreaChart {...props} />;
}
}
type ChildChartProps<T extends string> = {
data: DataPoint<T>[];
showYAxis?: boolean;
formatXAxis: (tickItem: string) => string;
handleMouseDown: (e: any) => void;
handleMouseMove: (e: any) => void;
handleMouseUp: () => void;
refAreaLeft: string | null;
refAreaRight: string | null;
chartConfig: ChartConfig;
dataKeys: string[];
};
function ChildBarChart<T extends string>({
data,
showYAxis = true,
formatXAxis,
handleMouseDown,
handleMouseMove,
handleMouseUp,
refAreaLeft,
refAreaRight,
chartConfig,
dataKeys,
}: ChildChartProps<T>) {
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{
left: 0,
right: 0,
top: 0,
bottom: 0,
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickFormatter={formatXAxis}
tickLine={false}
axisLine={false}
tickMargin={4}
minTickGap={16}
style={{ fontSize: '10px', userSelect: 'none' }}
/>
{showYAxis && (
<YAxis
tickLine={false}
axisLine={false}
tickMargin={4}
style={{ fontSize: '10px', userSelect: 'none' }}
/>
)}
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px] sm:w-[200px] font-mono text-xs sm:text-xs"
labelFormatter={(value) => new Date(value).toLocaleString()}
/>
}
/>
{dataKeys.map((key) => (
<Bar
key={key}
type="monotone"
dataKey={key}
stroke={chartConfig[key].color}
fillOpacity={1}
fill={chartConfig[key].color}
isAnimationActive={false}
/>
))}
{refAreaLeft && refAreaRight && (
<ReferenceArea
x1={refAreaLeft}
x2={refAreaRight}
strokeOpacity={0.3}
fill="hsl(var(--foreground))"
fillOpacity={0.1}
/>
)}
</BarChart>
</ResponsiveContainer>
);
}
function ChildLineChart<T extends string>({
data,
showYAxis = true,
formatXAxis,
handleMouseDown,
handleMouseMove,
handleMouseUp,
refAreaLeft,
refAreaRight,
chartConfig,
dataKeys,
}: ChildChartProps<T>) {
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{
left: 0,
right: 0,
top: 0,
bottom: 0,
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickFormatter={formatXAxis}
tickLine={false}
axisLine={false}
tickMargin={4}
minTickGap={16}
style={{ fontSize: '10px', userSelect: 'none' }}
/>
{showYAxis && (
<YAxis
tickLine={false}
axisLine={false}
tickMargin={4}
style={{ fontSize: '10px', userSelect: 'none' }}
/>
)}
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px] sm:w-[200px] font-mono text-xs sm:text-xs"
labelFormatter={(value) => new Date(value).toLocaleString()}
/>
}
/>
{dataKeys.map((key) => {
return (
<Line
key={key}
type="monotone"
dot={false}
dataKey={key}
stroke={chartConfig[key].color}
fillOpacity={1}
fill={chartConfig[key].color}
isAnimationActive={false}
/>
);
})}
{refAreaLeft && refAreaRight && (
<ReferenceArea
x1={refAreaLeft}
x2={refAreaRight}
strokeOpacity={0.3}
fill="hsl(var(--foreground))"
fillOpacity={0.1}
/>
)}
</LineChart>
</ResponsiveContainer>
);
}
function ChildAreaChart<T extends string>({
data,
showYAxis = true,
formatXAxis,
handleMouseDown,
handleMouseMove,
handleMouseUp,
refAreaLeft,
refAreaRight,
chartConfig,
dataKeys,
}: ChildChartProps<T>) {
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{
left: 0,
right: 0,
top: 0,
bottom: 0,
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickFormatter={formatXAxis}
tickLine={false}
axisLine={false}
tickMargin={4}
minTickGap={16}
style={{ fontSize: '10px', userSelect: 'none' }}
/>
{showYAxis && (
<YAxis
tickLine={false}
axisLine={false}
tickMargin={4}
style={{ fontSize: '10px', userSelect: 'none' }}
/>
)}
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px] sm:w-[200px] font-mono text-xs sm:text-xs"
labelFormatter={(value) => new Date(value).toLocaleString()}
/>
}
/>
{dataKeys.map((key) => {
return (
<Area
key={key}
type="monotone"
dot={false}
dataKey={key}
stroke={chartConfig[key].color}
fillOpacity={0.6}
fill={chartConfig[key].color}
isAnimationActive={false}
stackId="a"
/>
);
})}
{refAreaLeft && refAreaRight && (
<ReferenceArea
x1={refAreaLeft}
x2={refAreaRight}
strokeOpacity={0.3}
fill="hsl(var(--foreground))"
fillOpacity={0.1}
/>
)}
</AreaChart>
</ResponsiveContainer>
);
}
@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '@/next/lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center justify-center text-current')}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { cn } from '@/next/lib/utils';
import { Button } from '@/next/components/ui/button';
import { CheckIcon, Copy } from 'lucide-react';
import CodeStyleRender from './code-render';
import { Link } from 'react-router-dom';
interface CodeBlockProps {
title?: string;
language: string;
value: string;
className?: string;
noHeader?: boolean;
showLineNumbers?: boolean;
highlightLines?: number[];
highlightStrings?: string[];
link?: string;
}
export function CodeBlock({
noHeader = false,
title,
language,
value,
className,
highlightLines = [],
highlightStrings = [],
link,
...props
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const copyToClipboard = () => {
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div
className={cn(
'relative rounded-md overflow-hidden border border-muted',
className,
)}
>
{!noHeader && (
<div className="flex items-center justify-between px-2 bg-muted/50 border-b rounded-t-md">
<div className="text-xs text-muted-foreground font-mono">
{link ? (
<Link to={link} target="_blank" rel="noopener noreferrer">
{title || language}
</Link>
) : (
title || language
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={copyToClipboard}
className="h-8 px-2"
>
{copied ? (
<CheckIcon className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
)}
<div className={cn('relative')}>
<pre
className={cn(
'p-4 overflow-x-auto text-sm font-mono bg-muted/30 rounded-b-md',
)}
>
<CodeStyleRender
parsed={value}
language={language}
highlightLines={highlightLines}
highlightStrings={highlightStrings}
{...props}
/>
</pre>
</div>
</div>
);
}
@@ -0,0 +1,106 @@
import { useTheme } from '@/next/components/theme-provider';
import { useEffect, useState, useMemo } from 'react';
import { codeToHtml } from 'shiki';
interface CodeStyleRenderProps {
parsed: string;
language: string;
className?: string;
highlightLines?: number[];
highlightStrings?: string[];
showLineNumbers?: boolean;
}
const CodeStyleRender = ({
parsed,
language,
className = '',
highlightLines = [],
highlightStrings = [],
showLineNumbers = false,
}: CodeStyleRenderProps) => {
const [html, setHtml] = useState<string>('');
const { theme } = useTheme();
const themeName = useMemo(() => {
return theme === 'dark' ? 'houston' : 'github-light';
}, [theme]);
useEffect(() => {
const asyncHighlight = async () => {
// Trim trailing empty lines but preserve empty lines within the code
const trimmedCode = parsed.replace(/\n+$/, '');
try {
const highlightedHtml = await codeToHtml(trimmedCode, {
lang: language.toLowerCase(),
theme: themeName,
});
// Add highlight class to specified lines
const lines = highlightedHtml.split('\n');
const processedLines = lines.map((line, index) => {
let processedLine = line;
// First, handle line highlighting
if (highlightLines.includes(index + 1)) {
processedLine = processedLine.replace(
'<span',
'<span style="background-color: rgba(255, 255, 0, 0.2)"',
);
}
// Then, handle string highlighting for this line
if (highlightStrings.length > 0) {
// Only highlight strings if the line is highlighted or no lines are specified
if (
highlightLines.length === 0 ||
highlightLines.includes(index + 1)
) {
highlightStrings.forEach((str) => {
const regex = new RegExp(
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
'g',
);
processedLine = processedLine.replace(
regex,
(match) =>
`<span style="background-color: rgba(255, 165, 0, 0.4)">${match}</span>`,
);
});
}
}
return processedLine;
});
setHtml(processedLines.join('\n'));
} catch (error) {
console.error('Error highlighting code:', error);
// Try fallback theme if first fails
try {
const fallbackTheme = theme === 'dark' ? 'nord' : 'min-light';
const highlightedHtml = await codeToHtml(trimmedCode, {
lang: language.toLowerCase(),
theme: fallbackTheme,
});
setHtml(highlightedHtml);
} catch (fallbackError) {
console.error('Fallback highlighting failed:', fallbackError);
// Last resort fallback to plain text
setHtml(`<pre><code>${trimmedCode}</code></pre>`);
}
}
};
asyncHighlight();
}, [parsed, language, themeName, theme, highlightLines, highlightStrings]);
return (
<div
className={`code-block overflow-auto ${className} ${showLineNumbers ? 'show-line-numbers' : 'hide-line-numbers'}`}
dangerouslySetInnerHTML={{ __html: html }}
></div>
);
};
export default CodeStyleRender;
@@ -0,0 +1,60 @@
import React from 'react';
import { CodeBlock } from './code-block';
import { InlineCodeBlock } from './inline-code-block';
import { cn } from '@/next/lib/utils';
type CodeVariant = 'block' | 'inline';
export interface CodeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: CodeVariant;
language: string;
value: string;
title?: string;
noHeader?: boolean;
showLineNumbers?: boolean;
highlightLines?: number[];
highlightStrings?: string[];
link?: string;
}
export function Code({
variant = 'block',
language,
value,
title,
noHeader,
showLineNumbers = false,
highlightLines = [],
highlightStrings = [],
className,
link,
...props
}: CodeProps) {
switch (variant) {
case 'inline':
return (
<InlineCodeBlock
language={language}
value={value}
className={className}
{...props}
/>
);
case 'block':
default:
return (
<CodeBlock
language={language}
value={value}
title={title}
noHeader={noHeader}
showLineNumbers={showLineNumbers}
highlightLines={highlightLines}
highlightStrings={highlightStrings}
className={cn(className)}
link={link}
{...props}
/>
);
}
}
@@ -0,0 +1,75 @@
import { useCallback } from 'react';
import { Code, CodeProps } from './code';
import { useQuery } from '@tanstack/react-query';
import { useToast } from '@/next/hooks/utils/use-toast';
export interface GithubCodeProps extends Omit<CodeProps, 'value'> {
repo: string;
path: string;
branch?: string;
highlightLines?: number[];
showLineNumbers?: boolean;
}
export function GithubCode({
repo,
path,
branch = 'main',
highlightLines = [],
showLineNumbers = true,
...props
}: GithubCodeProps) {
const { toast } = useToast();
const fetchGithubCode = useCallback(
async (repo: string, path: string, branch: string): Promise<string> => {
const response = await fetch(
`https://raw.githubusercontent.com/${repo}/${branch}/${path}`,
);
if (!response.ok) {
toast({
title: 'Error fetching code',
description: `Failed to fetch code: ${response.statusText}`,
variant: 'destructive',
});
}
return response.text();
},
[toast],
);
const {
data: code,
isLoading,
error,
} = useQuery({
queryKey: ['github-code', repo, path, branch],
queryFn: () => fetchGithubCode(repo, path, branch),
retry: false,
});
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return (
<div className="text-red-500">
Error: {error instanceof Error ? error.message : 'Failed to fetch code'}
</div>
);
}
return (
<Code
value={code || ''}
highlightLines={highlightLines}
showLineNumbers={showLineNumbers}
{...props}
title={props.title || path}
link={`https://github.com/${repo}/${branch}/${path}`}
/>
);
}
@@ -0,0 +1 @@
export { Code, type CodeProps } from './code';
@@ -0,0 +1,74 @@
import { useState } from 'react';
import { cn } from '@/next/lib/utils';
import { Button } from '@/next/components/ui/button';
import { CheckIcon, Copy } from 'lucide-react';
import CodeStyleRender from './code-render';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/next/components/ui/tooltip';
interface InlineCodeBlockProps {
language: string;
value: string;
className?: string;
}
export function InlineCodeBlock({
language,
value,
className,
}: InlineCodeBlockProps) {
const [copied, setCopied] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const copyToClipboard = () => {
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<TooltipProvider>
<div
className={cn(
'relative inline-flex items-center rounded-md bg-muted/30 font-mono text-sm group',
className,
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="px-2 py-1">
<CodeStyleRender
parsed={value}
language={language}
className="inline"
/>
</div>
{isHovered && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={copyToClipboard}
className="h-6 w-6 p-0 absolute right-0 top-0 opacity-80 hover:opacity-100 hover:bg-background/50"
>
{copied ? (
<CheckIcon className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy code</p>
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,59 @@
import { useMemo } from 'react';
import { CodeBlock } from './code-block';
import { Snippet as SnippetType } from '@/next/lib/docs/snips';
interface SnippetProps {
src: SnippetType;
block?: keyof SnippetType['blocks'] | 'ALL';
}
const languageMap = {
typescript: 'ts',
python: 'py',
go: 'go',
unknown: 'txt',
};
// This will be rendered at build time
export const Snippet = ({ src, block }: SnippetProps) => {
if (!src.content) {
throw new Error(`src content is required: ${src.source}`);
}
const language = useMemo(() => {
const normalizedLanguage = src.language?.toLowerCase().trim();
if (normalizedLanguage && normalizedLanguage in languageMap) {
return languageMap[normalizedLanguage as keyof typeof languageMap];
}
return 'txt';
}, [src.language]);
let content = src.content;
if (block && block !== 'ALL' && src.blocks) {
if (!(block in src.blocks)) {
throw new Error(
`Block ${block} not found in ${src.source} ${JSON.stringify(src.blocks, null, 2)}`,
);
}
const lines = src.content.split('\n');
content = lines
.slice(src.blocks[block].start - 1, src.blocks[block].stop)
.join('\n');
}
const fixedSource = src.source.replace('out/', 'examples/');
return (
<>
<CodeBlock
value={content}
language={language}
// highlightLines={src.blocks?.[block]?.start}
// highlightLines={src.blocks?.[block]?.stop}
title={fixedSource}
link={`https://github.com/hatchet-dev/hatchet/blob/main/${fixedSource}`}
/>
</>
);
};
@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
@@ -0,0 +1,150 @@
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { cn } from '@/next/lib/utils';
import { Dialog, DialogContent } from '@/next/components/ui/dialog';
import { MagnifyingGlassIcon } from '@radix-ui/react-icons';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" data-cmdk-input-wrapper="">
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
@@ -0,0 +1,136 @@
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
} from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/next/components/ui/table';
import { Input } from '@/next/components/ui/input';
import { useState, ReactNode } from 'react';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
filters?: string[];
getRowId?: (row: TData) => string;
isLoading?: boolean;
emptyState?: ReactNode;
}
export function DataTable<TData, TValue>({
columns,
data,
filters = [],
getRowId,
isLoading,
emptyState,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
getRowId,
state: {
sorting,
columnFilters,
},
});
return (
<div>
<div className="flex items-center py-4">
{filters.map((filter) => (
<Input
key={filter}
placeholder={`Filter ${filter}...`}
value={(table.getColumn(filter)?.getFilterValue() as string) ?? ''}
onChange={(event) =>
table.getColumn(filter)?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
))}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
Loading...
</TableCell>
</TableRow>
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24">
{emptyState || (
<div className="flex flex-col items-center justify-center gap-4 py-8">
<p className="text-md">No results found.</p>
</div>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -0,0 +1,120 @@
import * as React from 'react';
import { format } from 'date-fns';
import { Calendar as CalendarIcon } from 'lucide-react';
import { DateRange } from 'react-day-picker';
import { cn } from '@/next/lib/utils';
import { Button } from '@/next/components/ui/button';
import { Calendar } from '@/next/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/next/components/ui/popover';
import { TimePicker } from '@/next/components/ui/time-picker';
interface DateRangePickerProps {
value?: DateRange;
onChange: (value: DateRange | undefined) => void;
timezone?: string;
placeholder?: string;
}
export function DateRangePicker({
value,
onChange,
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
placeholder = 'Select date range',
}: DateRangePickerProps) {
const [, setStartTime] = React.useState<Date | undefined>(value?.from);
const [, setEndTime] = React.useState<Date | undefined>(value?.to);
// Update time values when the date range changes
React.useEffect(() => {
setStartTime(value?.from);
setEndTime(value?.to);
}, [value?.from, value?.to]);
// Handle start time change
const handleStartTimeChange = (date: Date | undefined) => {
if (!date || !value?.from) {
return;
}
const newFrom = new Date(value.from);
newFrom.setHours(date.getHours());
newFrom.setMinutes(date.getMinutes());
onChange({ ...value, from: newFrom });
};
// Handle end time change
const handleEndTimeChange = (date: Date | undefined) => {
if (!date || !value?.to) {
return;
}
const newTo = new Date(value.to);
newTo.setHours(date.getHours());
newTo.setMinutes(date.getMinutes());
onChange({ ...value, to: newTo });
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-full justify-start text-left',
!value && 'text-muted-foreground',
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value?.from ? (
value.to ? (
<>
{format(value.from, 'LLL dd, y HH:mm')} -{' '}
{format(value.to, 'LLL dd, y HH:mm')}
</>
) : (
format(value.from, 'LLL dd, y HH:mm')
)
) : (
placeholder
)}
{timezone && (
<span className="ml-1 text-xs text-muted-foreground">
({timezone})
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-3 space-y-3">
<div className="grid gap-2">
<Calendar
initialFocus
mode="range"
defaultMonth={value?.from}
selected={value}
onSelect={onChange}
numberOfMonths={2}
/>
</div>
<div className="grid grid-cols-2 gap-4 border-t pt-3">
<div className="space-y-1">
<div className="text-sm font-medium">Start Time</div>
<TimePicker date={value?.from} setDate={handleStartTimeChange} />
</div>
<div className="space-y-1">
<div className="text-sm font-medium">End Time</div>
<TimePicker date={value?.to} setDate={handleEndTimeChange} />
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,121 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '@/next/lib/utils';
import { Cross2Icon } from '@radix-ui/react-icons';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,43 @@
import { DestructiveDialog } from './destructive-dialog';
interface BulkActionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
confirmButtonText: string;
isLoading?: boolean;
onConfirm: () => void;
onCancel?: () => void;
children?: React.ReactNode;
}
export function BulkActionDialog({
open,
onOpenChange,
title,
description,
confirmButtonText,
isLoading = false,
onConfirm,
onCancel,
children,
}: BulkActionDialogProps) {
return (
<DestructiveDialog
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
confirmationText=""
confirmButtonText={confirmButtonText}
isLoading={isLoading}
requireTextConfirmation={false}
onConfirm={onConfirm}
onCancel={onCancel}
hideAlert={true}
>
{children}
</DestructiveDialog>
);
}
@@ -0,0 +1,133 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { Input } from '@/next/components/ui/input';
import { Button } from '@/next/components/ui/button';
import {
Alert,
AlertTitle,
AlertDescription,
} from '@/next/components/ui/alert';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/next/components/ui/dialog/dialog';
import { FaBomb } from 'react-icons/fa';
import { Code } from '@/next/components/ui/code';
interface DestructiveDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: React.ReactNode;
confirmationText: string;
confirmButtonText: string;
cancelButtonText?: string;
isLoading?: boolean;
requireTextConfirmation?: boolean;
onConfirm: () => void;
onCancel?: () => void;
children?: React.ReactNode;
alertTitle?: string;
alertDescription?: React.ReactNode;
hideAlert?: boolean;
submitVariant?: 'default' | 'destructive';
}
export function DestructiveDialog({
open,
onOpenChange,
title,
description,
confirmationText,
confirmButtonText,
cancelButtonText = 'Cancel',
isLoading = false,
requireTextConfirmation = true,
onConfirm,
onCancel,
children,
alertTitle = 'Destructive Action',
alertDescription = 'This action cannot be undone. Please review carefully before proceeding.',
hideAlert = false,
submitVariant = 'destructive',
}: DestructiveDialogProps) {
const [inputValue, setInputValue] = useState('');
const isConfirmationValid = inputValue === confirmationText;
// Reset input value when dialog opens/closes
useEffect(() => {
if (!open) {
setInputValue('');
}
}, [open]);
const handleCancel = () => {
onOpenChange(false);
if (onCancel) {
onCancel();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
{children}
{requireTextConfirmation && (
<div className="mt-2">
<label
htmlFor="confirmation"
className="block text-sm font-medium mb-2"
>
Type{' '}
<Code language="text" value={confirmationText} variant="inline" />
to confirm
</label>
<Input
id="confirmation"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={`Type "${confirmationText}" to confirm`}
className={
!isConfirmationValid && inputValue ? 'border-red-500' : ''
}
/>
</div>
)}
{!hideAlert && (
<Alert variant="destructive" className="my-2">
<FaBomb className="h-4 w-4" />
<AlertTitle>{alertTitle}</AlertTitle>
<AlertDescription>{alertDescription}</AlertDescription>
</Alert>
)}
<DialogFooter className="mt-2">
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
{cancelButtonText}
</Button>
<Button
variant={submitVariant}
onClick={onConfirm}
disabled={
(requireTextConfirmation && !isConfirmationValid) || isLoading
}
className="ml-2"
>
{isLoading ? 'Processing...' : confirmButtonText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,112 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '@/next/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = ({ ...props }: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal {...props} />
);
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full',
className,
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end gap-2',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,3 @@
export * from './dialog';
export * from './destructive-dialog';
export * from './bulk-action-dialog';
@@ -0,0 +1,72 @@
import * as React from 'react';
import { Button, ButtonProps } from './button';
import { BookOpenIcon } from 'lucide-react';
import { DocRef, useDocs } from '@/next/hooks/use-docs-sheet';
import { cn } from '@/next/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from './tooltip';
interface DocsButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
prefix?: string;
doc: DocRef;
size?: ButtonProps['size'];
method?: 'sheet' | 'link';
variant?: ButtonProps['variant'];
titleOverride?: string;
}
const baseDocsUrl = 'https://docs.hatchet.run';
export function DocsButton({
doc,
prefix = 'Learn more about ',
size = 'sm',
method = 'sheet',
variant = 'outline',
titleOverride,
...props
}: DocsButtonProps) {
const { open } = useDocs();
const handleClick = (e: React.MouseEvent) => {
if (method === 'sheet') {
e.preventDefault();
open(doc);
} else {
window.open(`${baseDocsUrl}${doc.href}`, '_blank');
}
};
const buttonContent = (
<Button variant={variant} {...props} size={size} onClick={handleClick}>
<BookOpenIcon className={cn('w-4 h-4', size === 'icon' && 'w-6 h-6')} />
{size !== 'icon' && (
<span>
{prefix} {titleOverride || doc.title}
</span>
)}
</Button>
);
if (size === 'icon') {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{buttonContent}</TooltipTrigger>
<TooltipContent>
<p>
{prefix} {doc.title}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return buttonContent;
}
@@ -0,0 +1,110 @@
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './sheet';
import { DocsSheet } from '@/next/hooks/use-docs-sheet';
import { useIsMobile } from '@/next/hooks/use-mobile';
import { Cross2Icon, ExternalLinkIcon } from '@radix-ui/react-icons';
interface DocsSheetProps {
sheet: DocsSheet;
onClose: () => void;
variant?: 'overlay' | 'push';
}
export function DocsSheetComponent({
sheet,
onClose,
variant = 'push',
}: DocsSheetProps) {
const isMobile = useIsMobile();
// If using push variant, render as a side panel instead of using Sheet
if (variant === 'push' && !isMobile) {
return (
<div
className={`
h-full min-h-screen bg-background border-l border-border
transition-all duration-300 ease-in-out
${sheet.isOpen ? 'lg:w-[600px] md:w-[400px] w-[300px]' : 'w-0 overflow-hidden'}
`}
>
{sheet.isOpen && (
<div className="h-full min-h-screen flex flex-col p-4 md:p-6 overflow-hidden">
<div className="flex justify-between items-center mb-4 shrink-0">
<h2 className="text-lg font-semibold truncate pr-2">
{sheet.title}
</h2>
<div className="flex items-center gap-2">
{sheet.url && (
<a
href={sheet.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 flex-shrink-0"
title="Open in new tab"
>
<ExternalLinkIcon className="h-4 w-4" />
<span className="sr-only">Open in new tab</span>
</a>
)}
<button
onClick={onClose}
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 flex-shrink-0"
>
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
</div>
<div className="flex-1 overflow-hidden relative">
{sheet.url && (
<iframe
src={sheet.url}
className="absolute inset-0 w-full h-full rounded-md border"
title={`Documentation: ${sheet.title}`}
loading="lazy"
/>
)}
</div>
</div>
)}
</div>
);
}
// Fall back to the overlay variant if not using push
return (
<Sheet open={sheet.isOpen} onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
className="p-4 md:p-6 w-full max-w-[300px] sm:max-w-[400px] lg:max-w-[600px]"
>
<SheetHeader className="mb-4 pr-8">
<div className="flex justify-between items-center">
<SheetTitle className="truncate">{sheet.title}</SheetTitle>
{sheet.url && (
<a
href={sheet.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 flex-shrink-0"
title="Open in new tab"
>
<ExternalLinkIcon className="h-4 w-4" />
<span className="sr-only">Open in new tab</span>
</a>
)}
</div>
</SheetHeader>
<div className="h-[calc(100vh-120px)] w-full relative overflow-hidden">
{sheet.url && (
<iframe
src={sheet.url}
className="absolute inset-0 w-full h-full rounded-md border"
title={`Documentation: ${sheet.title}`}
loading="lazy"
/>
)}
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,202 @@
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { cn } from '@/next/lib/utils';
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from '@radix-ui/react-icons';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
@@ -0,0 +1,103 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/next/lib/utils';
import { intervalToDuration } from 'date-fns';
import { Clock } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/next/components/ui/tooltip';
import { formatDuration } from '@/next/lib/utils/formatDuration';
import { V1TaskStatus } from '@/lib/api';
import { isValidTimestamp } from './time';
const durationVariants = cva('text-sm', {
variants: {
variant: {
default: 'text-muted-foreground',
compact: 'font-mono text-xs',
},
},
defaultVariants: {
variant: 'default',
},
});
export interface DurationProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof durationVariants> {
start?: string | Date | null;
end?: string | Date | null;
status?: V1TaskStatus;
showIcon?: boolean;
asChild?: boolean;
}
export function Duration({
className,
variant,
start,
end,
status,
showIcon = true,
asChild,
...props
}: DurationProps) {
if (!isValidTimestamp(start)) {
return (
<div
className={cn(!asChild && durationVariants({ variant }), className)}
{...props}
>
-
</div>
);
}
const startDate = new Date(start!);
const endDate = isValidTimestamp(end) ? new Date(end!) : new Date();
const duration = intervalToDuration({ start: startDate, end: endDate });
const rawDuration = endDate.getTime() - startDate.getTime();
const isRunning = status === 'RUNNING';
const content = (
<div className="flex items-center gap-1">
{showIcon && <Clock className="h-3.5 w-3.5" />}
<span className={isRunning ? 'animate-pulse' : ''}>
{formatDuration(duration, rawDuration)}
{isRunning && '...'}
</span>
</div>
);
if (variant === 'compact') {
return (
<div
className={cn(!asChild && durationVariants({ variant }), className)}
{...props}
>
{content}
</div>
);
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(!asChild && durationVariants({ variant }), className)}
{...props}
>
{content}
</div>
</TooltipTrigger>
<TooltipContent>
{isRunning ? 'Running for ' : ''}
{formatDuration(duration, rawDuration)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,384 @@
import * as React from 'react';
import { cn } from '@/next/lib/utils';
import { Input } from '@/next/components/ui/input';
import { useFilters } from '@/next/hooks/utils/use-filters';
import { Badge } from '@/next/components/ui/badge';
import { Check, X } from 'lucide-react';
import { Button } from '@/next/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from '@/next/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/next/components/ui/popover';
import useDefinitions from '@/next/hooks/use-definitions';
import { useMemo } from 'react';
import { PlusCircledIcon } from '@radix-ui/react-icons';
interface FiltersProps {
children?: React.ReactNode;
className?: string;
}
interface FilterBuilderProps<T> {
name: keyof T;
placeholder?: string;
className?: string;
}
export function ClearFiltersButton() {
const { filters, clearAllFilters } = useFilters<Record<string, unknown>>();
const hasActiveFilters = Object.values(filters).some(
(value) => value !== undefined && value !== null && value !== '',
);
if (!hasActiveFilters) {
return null;
}
return (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4 mr-1" />
Clear all
</Button>
);
}
export function FilterGroup({ className, children, ...props }: FiltersProps) {
return (
<div
role="filters"
aria-label="filters"
className={cn('flex w-full items-center gap-2 md:gap-6', className)}
{...props}
>
{children}
</div>
);
}
interface TextFilterProps<T> extends FilterBuilderProps<T> {
value?: string;
}
export function FilterText<T>({
name,
placeholder,
className,
}: TextFilterProps<T>) {
const { filters, setFilter } = useFilters<T>();
const value = filters[name];
return (
<Input
type="text"
value={value as string}
onChange={(e) =>
setFilter(
name,
e.target.value
? (e.target.value as T[keyof T])
: (undefined as T[keyof T]),
)
}
placeholder={placeholder}
className={cn('flex-grow h-8 rounded-md px-3 text-xs', className)}
/>
);
}
type ArrayElement<T> = T extends (infer U)[] ? U : T;
interface MultiSelectFilterProps<T, A, Key extends keyof T = keyof T>
extends FilterBuilderProps<T> {
multi?: boolean;
name: Key;
only?: boolean;
options: {
label: React.ReactNode;
value: ArrayElement<A>;
text?: string;
}[];
value?: A;
}
export function FilterSelect<T, A>({
name,
options,
placeholder,
multi = false,
only = false,
}: MultiSelectFilterProps<T, A>) {
const { filters, setFilter } = useFilters<T>();
const value = filters[name] as Array<ArrayElement<A>> | undefined;
const [open, setOpen] = React.useState(false);
const handleSelect = (optionValue: ArrayElement<A>) => {
if (multi && value?.includes(optionValue)) {
setFilter(
name,
value.filter((v) => v !== optionValue) as unknown as T[keyof T],
);
} else if (!multi && value === optionValue) {
setFilter(name, optionValue as T[keyof T]);
} else {
setFilter(
name,
multi
? ([...(value || []), optionValue] as unknown as T[keyof T])
: (optionValue as T[keyof T]),
);
}
};
const selectedOptions = multi
? value?.map((v) => options.find((o) => o.value === v)).filter(Boolean) ||
[]
: value
? [options.find((o) => o.value === value)]
: [];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className="w-fit min-w-fit gap-2"
>
<PlusCircledIcon className="h-4 w-4" />
<div className="flex flex-wrap gap-1">
{selectedOptions.length > 0 ? (
selectedOptions.map((option, index) => (
<Badge key={index} variant="secondary" className="mr-1">
{option?.label}
</Badge>
))
) : (
<span className="text-foreground">{placeholder}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" side="bottom" align="start">
<Command>
<CommandInput placeholder={placeholder} />
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-auto">
{options.map((option, index) => (
<CommandItem
key={index}
onSelect={() => {
handleSelect(option.value);
}}
className="group"
>
<div className="flex items-center space-x-2 justify-between">
<div
className={cn(
'flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
(multi && value?.includes(option.value)) ||
(!multi && value === option.value)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
{multi && <Check className="h-4 w-4" />}
</div>
<span>{option.text || option.label}</span>
</div>
{multi && only && (
<Badge
variant="outline"
className="ml-auto opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setFilter(name, [option.value] as T[keyof T]);
}}
>
Only
</Badge>
)}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}
interface FilterTaskSelectProps<T> extends FilterBuilderProps<T> {
multi?: boolean;
only?: boolean;
value?: string;
}
export function FilterTaskSelect<T>({ ...props }: FilterTaskSelectProps<T>) {
const { data: options = [] } = useDefinitions();
return (
<FilterSelect<T, string>
options={options.map((o) => ({
label: o.name,
value: o.metadata.id,
}))}
{...props}
/>
);
}
interface FilterKeyValueProps<T> extends FilterBuilderProps<T> {
options?: {
label: string;
value: string;
}[];
}
export function FilterKeyValue<T>({
name,
options = [],
placeholder = 'Add filter',
}: FilterKeyValueProps<T>) {
const { filters, setFilter } = useFilters<T>();
const [open, setOpen] = React.useState(false);
const [key, setKey] = React.useState<string>('');
const [value, setValue] = React.useState<string>('');
const currentFilters = useMemo(
() => (filters[name] as string[]) || [],
[filters, name],
);
const handleAddFilter = () => {
if (key && value) {
const newFilter = `${key}:${value}`;
// Check if this exact key-value pair already exists
if (currentFilters.includes(newFilter)) {
return; // Don't add duplicate
}
setFilter(name, [...currentFilters, newFilter] as T[keyof T]);
setKey('');
setValue('');
setOpen(false);
}
};
const handleRemoveFilter = (index: number) => {
setFilter(name, currentFilters.filter((_, i) => i !== index) as T[keyof T]);
};
// Get unique keys from current filters
const existingKeys = useMemo(() => {
return new Set(currentFilters.map((filter) => filter.split(':')[0]));
}, [currentFilters]);
// Filter and deduplicate options
const filteredOptions = useMemo(() => {
const seen = new Set<string>();
return options
.filter(
(option) =>
option.label.toLowerCase().includes(key.toLowerCase()) &&
!existingKeys.has(option.label) &&
option.label !== key,
)
.filter((option) => {
if (seen.has(option.label)) {
return false;
}
seen.add(option.label);
return true;
});
}, [options, key, existingKeys]);
return (
<div className="flex flex-col gap-2">
{currentFilters.length > 0 && (
<div className="flex flex-wrap gap-2">
{currentFilters.map((filter, index) => {
const [key, value] = filter.split(':');
return (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
<span>{key === value ? value : `${key}: ${value}`}</span>
<button
onClick={() => handleRemoveFilter(index)}
className="ml-1 hover:brightness-75"
>
×
</button>
</Badge>
);
})}
</div>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-start text-foreground gap-2"
>
<PlusCircledIcon className="h-4 w-4" />
{placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Input
placeholder="Enter key"
value={key}
onChange={(e) => setKey(e.target.value)}
/>
{filteredOptions.length > 0 && (
<div className="flex flex-wrap gap-1">
{filteredOptions.map((option) => (
<Badge
key={option.value}
variant="outline"
className="cursor-pointer"
onClick={() => setKey(option.label)}
>
{option.label}
</Badge>
))}
</div>
)}
</div>
<Input
placeholder="Enter value"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<Button
onClick={handleAddFilter}
disabled={
!key || !value || currentFilters.includes(`${key}:${value}`)
}
>
Add Filter
</Button>
</div>
</PopoverContent>
</Popover>
</div>
);
}
@@ -0,0 +1,4 @@
import { FilterGroup } from './filters';
import { FilterText } from './filters';
export { FilterGroup, FilterText };
@@ -0,0 +1,10 @@
import { TimeFilter, TimeFilterGroup, TogglePause } from './time-filter';
export function TimeFilters() {
return (
<TimeFilterGroup className="w-full justify-end">
<TimeFilter />
<TogglePause />
</TimeFilterGroup>
);
}
@@ -0,0 +1,173 @@
import { DateTimePicker } from '@/components/molecules/time-picker/date-time-picker';
import { cn } from '@/next/lib/utils';
import { Button } from '@/next/components/ui/button';
import {
TIME_PRESETS,
useTimeFilters,
} from '@/next/hooks/utils/use-time-filters';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/next/components/ui/dropdown-menu';
import { ChevronDown } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/next/components/ui/tooltip';
import { TbSnowflake, TbSnowflakeOff } from 'react-icons/tb';
interface TimeFilterProps {
startField?: string;
endField?: string;
className?: string;
}
interface FiltersProps {
children?: React.ReactNode;
className?: string;
}
export function TimeFilterGroup({
className,
children,
...props
}: FiltersProps) {
return (
<div
role="filters"
aria-label="filters"
className={cn('flex w-full items-center gap-2', className)}
{...props}
>
{children}
</div>
);
}
export function TogglePause() {
const { pause, resume, isPaused } = useTimeFilters();
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isPaused ? 'default' : 'outline'}
size="sm"
className="h-8 px-2 text-xs"
onClick={() => {
if (isPaused) {
resume();
} else {
pause();
}
}}
>
{isPaused ? (
<>
<TbSnowflakeOff className="h-4 w-4" />
<span className="text-xs">Unfreeze</span>
</>
) : (
<TbSnowflake className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPaused ? undefined : 'Freeze new runs'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function TimeFilter({ className }: TimeFilterProps) {
const {
filters,
setTimeFilter,
activePreset,
handleTimeFilterChange,
handleClearTimeFilters,
pause,
isPaused,
} = useTimeFilters();
const startDate = filters.startTime
? new Date(filters.startTime as string)
: undefined;
const endDate = filters.endTime
? new Date(filters.endTime as string)
: undefined;
return (
<div className={cn('flex flex-col', className)}>
{!isPaused ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-2 text-xs flex items-center gap-1"
>
Last {activePreset || 'Select Time Range'}
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{Object.entries(TIME_PRESETS).map(([key]) => (
<DropdownMenuItem
key={key}
onClick={() =>
handleTimeFilterChange(key as keyof typeof TIME_PRESETS)
}
className={cn(
'cursor-pointer',
activePreset === key && 'bg-accent',
)}
>
Last {key}
</DropdownMenuItem>
))}
<DropdownMenuItem onClick={() => pause()}>
Custom Range
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div className="flex items-center gap-4">
<DateTimePicker
date={startDate}
setDate={(date) => {
if (date) {
setTimeFilter({
startTime: date?.toISOString(),
endTime: endDate?.toISOString(),
});
} else {
handleClearTimeFilters();
}
}}
label="Start Time"
/>
<DateTimePicker
date={endDate}
setDate={(date) => {
if (date) {
setTimeFilter({
startTime: startDate!.toISOString(),
endTime: date?.toISOString(),
});
} else {
handleClearTimeFilters();
}
}}
label="End Time"
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,66 @@
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './sheet';
import { useIsMobile } from '@/next/hooks/use-mobile';
import { Cross2Icon } from '@radix-ui/react-icons';
interface InfoSheetProps {
isOpen: boolean;
onClose: () => void;
title: React.ReactNode;
children: React.ReactNode;
variant?: 'overlay' | 'push';
}
export function InfoSheet({
isOpen,
onClose,
title,
children,
variant = 'push',
}: InfoSheetProps) {
const isMobile = useIsMobile();
// If using push variant, render as a side panel instead of using Sheet
if (variant === 'push' && !isMobile) {
return (
<div
className={`
border-l border-border
${isOpen ? 'lg:w-[600px] md:w-[400px] w-[300px]' : 'w-0 overflow-hidden'}
`}
>
{isOpen && (
<div className="h-full flex flex-col">
<div className="flex justify-between items-center p-4 border-b">
<h2 className="text-lg font-semibold truncate pr-2">{title}</h2>
<button
onClick={onClose}
className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 flex-shrink-0"
>
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">{children}</div>
</div>
)}
</div>
);
}
// Fall back to the overlay variant if not using push
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
className="p-4 md:p-6 w-full max-w-[300px] sm:max-w-[400px] lg:max-w-[600px]"
>
<SheetHeader className="mb-4 pr-8">
<SheetTitle className="truncate">{title}</SheetTitle>
</SheetHeader>
<div className="h-[calc(100vh-120px)] w-full relative overflow-hidden">
{children}
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,23 @@
import * as React from 'react';
import { cn } from '@/next/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
data-1p-ignore
/>
);
},
);
Input.displayName = 'Input';
export { Input };
@@ -0,0 +1,24 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/next/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

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