mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2026-04-23 10:39:45 -05:00
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:
@@ -31,6 +31,8 @@ node_modules
|
||||
# Jetbrains IDEs
|
||||
*.iml
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Local docs directories
|
||||
/docs/.obsidian
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
"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",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification 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
|
||||
|
||||
@@ -43,3 +43,4 @@ func OnCron(hatchet v1.HatchetClient) workflow.WorkflowDeclaration[OnCronInput,
|
||||
|
||||
return cronTask
|
||||
}
|
||||
|
||||
|
||||
@@ -49,3 +49,4 @@ func Priority(hatchet v1.HatchetClient) workflow.WorkflowDeclaration[PriorityInp
|
||||
)
|
||||
return workflow
|
||||
}
|
||||
|
||||
|
||||
@@ -93,3 +93,4 @@ func RateLimit(hatchet v1.HatchetClient) workflow.WorkflowDeclaration[RateLimitI
|
||||
|
||||
return rateLimitTask
|
||||
}
|
||||
|
||||
|
||||
@@ -81,3 +81,4 @@ func main() {
|
||||
|
||||
// ,
|
||||
}
|
||||
|
||||
|
||||
@@ -105,3 +105,4 @@ func main() {
|
||||
}
|
||||
// ,
|
||||
}
|
||||
|
||||
|
||||
@@ -95,3 +95,4 @@ func main() {
|
||||
|
||||
// ,
|
||||
}
|
||||
|
||||
|
||||
@@ -17,3 +17,5 @@ async def spawn(input: EmptyModel, ctx: Context) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
return {"results": result}
|
||||
|
||||
|
||||
|
||||
@@ -15,3 +15,4 @@ hatchet = Hatchet(
|
||||
logger=root_logger,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+712
-14
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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
@@ -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
Reference in New Issue
Block a user