feat: more cron webui
This commit is contained in:
parent
004fed9b2e
commit
3aad31a36b
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -6757,6 +6757,7 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"chrono-tz 0.10.3",
|
||||||
"clap",
|
"clap",
|
||||||
"cocoon",
|
"cocoon",
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
|
@ -169,6 +169,7 @@ croner = "2.2.0"
|
|||||||
ts-rs = "11.0.1"
|
ts-rs = "11.0.1"
|
||||||
secrecy = { version = "0.10.3", features = ["serde"] }
|
secrecy = { version = "0.10.3", features = ["serde"] }
|
||||||
paste = "1.0.15"
|
paste = "1.0.15"
|
||||||
|
chrono-tz = "0.10.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
inquire = { workspace = true }
|
inquire = { workspace = true }
|
||||||
|
@ -18,6 +18,8 @@ use crate::{
|
|||||||
#[derive(Snafu, Debug)]
|
#[derive(Snafu, Debug)]
|
||||||
#[snafu(visibility(pub(crate)))]
|
#[snafu(visibility(pub(crate)))]
|
||||||
pub enum RecorderError {
|
pub enum RecorderError {
|
||||||
|
#[snafu(transparent)]
|
||||||
|
ChronoTzParseError { source: chrono_tz::ParseError },
|
||||||
#[snafu(transparent)]
|
#[snafu(transparent)]
|
||||||
SeaographyError { source: seaography::SeaographyError },
|
SeaographyError { source: seaography::SeaographyError },
|
||||||
#[snafu(transparent)]
|
#[snafu(transparent)]
|
||||||
|
@ -20,6 +20,7 @@ fn skip_columns_for_entity_input(context: &mut BuilderContext) {
|
|||||||
cron::Column::SubscriberTaskCron
|
cron::Column::SubscriberTaskCron
|
||||||
| cron::Column::SystemTaskCron
|
| cron::Column::SystemTaskCron
|
||||||
| cron::Column::CronExpr
|
| cron::Column::CronExpr
|
||||||
|
| cron::Column::CronTimezone
|
||||||
| cron::Column::Enabled
|
| cron::Column::Enabled
|
||||||
| cron::Column::TimeoutMs
|
| cron::Column::TimeoutMs
|
||||||
| cron::Column::MaxAttempts
|
| cron::Column::MaxAttempts
|
||||||
@ -30,7 +31,8 @@ fn skip_columns_for_entity_input(context: &mut BuilderContext) {
|
|||||||
context.entity_input.insert_skips.push(entity_column_key);
|
context.entity_input.insert_skips.push(entity_column_key);
|
||||||
}
|
}
|
||||||
for column in cron::Column::iter() {
|
for column in cron::Column::iter() {
|
||||||
if matches!(column, |cron::Column::CronExpr| cron::Column::Enabled
|
if matches!(column, |cron::Column::CronExpr| cron::Column::CronTimezone
|
||||||
|
| cron::Column::Enabled
|
||||||
| cron::Column::TimeoutMs
|
| cron::Column::TimeoutMs
|
||||||
| cron::Column::Priority
|
| cron::Column::Priority
|
||||||
| cron::Column::MaxAttempts)
|
| cron::Column::MaxAttempts)
|
||||||
|
@ -178,6 +178,7 @@ pub enum Cron {
|
|||||||
SubscriberId,
|
SubscriberId,
|
||||||
SubscriptionId,
|
SubscriptionId,
|
||||||
CronExpr,
|
CronExpr,
|
||||||
|
CronTimezone,
|
||||||
NextRun,
|
NextRun,
|
||||||
LastRun,
|
LastRun,
|
||||||
LastError,
|
LastError,
|
||||||
|
@ -40,6 +40,7 @@ impl MigrationTrait for Migration {
|
|||||||
table_auto_z(Cron::Table)
|
table_auto_z(Cron::Table)
|
||||||
.col(pk_auto(Cron::Id))
|
.col(pk_auto(Cron::Id))
|
||||||
.col(string(Cron::CronExpr))
|
.col(string(Cron::CronExpr))
|
||||||
|
.col(string(Cron::CronTimezone))
|
||||||
.col(integer_null(Cron::SubscriberId))
|
.col(integer_null(Cron::SubscriberId))
|
||||||
.col(integer_null(Cron::SubscriptionId))
|
.col(integer_null(Cron::SubscriptionId))
|
||||||
.col(timestamp_with_time_zone_null(Cron::NextRun))
|
.col(timestamp_with_time_zone_null(Cron::NextRun))
|
||||||
|
@ -8,6 +8,7 @@ pub use core::{
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use chrono_tz::Tz;
|
||||||
use croner::Cron;
|
use croner::Cron;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveValue::{self, Set},
|
ActiveValue::{self, Set},
|
||||||
@ -54,6 +55,7 @@ pub struct Model {
|
|||||||
pub subscriber_id: Option<i32>,
|
pub subscriber_id: Option<i32>,
|
||||||
pub subscription_id: Option<i32>,
|
pub subscription_id: Option<i32>,
|
||||||
pub cron_expr: String,
|
pub cron_expr: String,
|
||||||
|
pub cron_timezone: String,
|
||||||
pub next_run: Option<DateTimeUtc>,
|
pub next_run: Option<DateTimeUtc>,
|
||||||
pub last_run: Option<DateTimeUtc>,
|
pub last_run: Option<DateTimeUtc>,
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
@ -140,16 +142,37 @@ impl ActiveModelBehavior for ActiveModel {
|
|||||||
where
|
where
|
||||||
C: ConnectionTrait,
|
C: ConnectionTrait,
|
||||||
{
|
{
|
||||||
if let ActiveValue::Set(ref cron_expr) = self.cron_expr
|
match (
|
||||||
&& matches!(
|
&self.cron_expr as &ActiveValue<String>,
|
||||||
self.next_run,
|
&self.cron_timezone as &ActiveValue<String>,
|
||||||
ActiveValue::NotSet | ActiveValue::Unchanged(_)
|
) {
|
||||||
)
|
(ActiveValue::Set(cron_expr), ActiveValue::Set(timezone)) => {
|
||||||
{
|
if matches!(
|
||||||
let next_run =
|
&self.next_run,
|
||||||
Model::calculate_next_run(cron_expr).map_err(|e| DbErr::Custom(e.to_string()))?;
|
ActiveValue::NotSet | ActiveValue::Unchanged(_)
|
||||||
self.next_run = Set(Some(next_run));
|
) {
|
||||||
}
|
let next_run = Model::calculate_next_run(cron_expr, timezone)
|
||||||
|
.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||||
|
self.next_run = Set(Some(next_run));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(
|
||||||
|
ActiveValue::Unchanged(_) | ActiveValue::NotSet,
|
||||||
|
ActiveValue::Unchanged(_) | ActiveValue::NotSet,
|
||||||
|
) => {}
|
||||||
|
(_, _) => {
|
||||||
|
if matches!(
|
||||||
|
self.next_run,
|
||||||
|
ActiveValue::NotSet | ActiveValue::Unchanged(_)
|
||||||
|
) {
|
||||||
|
return Err(DbErr::Custom(
|
||||||
|
"Cron expr and timezone must be insert or update at same time when next \
|
||||||
|
run is not set"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
if let ActiveValue::Set(Some(subscriber_id)) = self.subscriber_id
|
if let ActiveValue::Set(Some(subscriber_id)) = self.subscriber_id
|
||||||
&& let ActiveValue::Set(Some(ref subscriber_task)) = self.subscriber_task_cron
|
&& let ActiveValue::Set(Some(ref subscriber_task)) = self.subscriber_task_cron
|
||||||
&& subscriber_task.get_subscriber_id() != subscriber_id
|
&& subscriber_task.get_subscriber_id() != subscriber_id
|
||||||
@ -272,7 +295,7 @@ impl Model {
|
|||||||
async fn mark_cron_completed(&self, ctx: &dyn AppContextTrait) -> RecorderResult<()> {
|
async fn mark_cron_completed(&self, ctx: &dyn AppContextTrait) -> RecorderResult<()> {
|
||||||
let db = ctx.db();
|
let db = ctx.db();
|
||||||
|
|
||||||
let next_run = Self::calculate_next_run(&self.cron_expr)?;
|
let next_run = Self::calculate_next_run(&self.cron_expr, &self.cron_timezone)?;
|
||||||
|
|
||||||
ActiveModel {
|
ActiveModel {
|
||||||
id: Set(self.id),
|
id: Set(self.id),
|
||||||
@ -310,7 +333,10 @@ impl Model {
|
|||||||
let next_run = if should_retry {
|
let next_run = if should_retry {
|
||||||
Some(Utc::now() + retry_duration)
|
Some(Utc::now() + retry_duration)
|
||||||
} else {
|
} else {
|
||||||
Some(Self::calculate_next_run(&self.cron_expr)?)
|
Some(Self::calculate_next_run(
|
||||||
|
&self.cron_expr,
|
||||||
|
&self.cron_timezone,
|
||||||
|
)?)
|
||||||
};
|
};
|
||||||
|
|
||||||
ActiveModel {
|
ActiveModel {
|
||||||
@ -399,11 +425,17 @@ impl Model {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn calculate_next_run(cron_expr: &str) -> RecorderResult<DateTime<Utc>> {
|
pub fn calculate_next_run(cron_expr: &str, timezone: &str) -> RecorderResult<DateTime<Utc>> {
|
||||||
|
let user_tz = timezone.parse::<Tz>()?;
|
||||||
|
|
||||||
|
let user_tz_now = Utc::now().with_timezone(&user_tz);
|
||||||
|
|
||||||
let cron_expr = Cron::new(cron_expr).with_seconds_optional().parse()?;
|
let cron_expr = Cron::new(cron_expr).with_seconds_optional().parse()?;
|
||||||
|
|
||||||
let next = cron_expr.find_next_occurrence(&Utc::now(), false)?;
|
let next = cron_expr.find_next_occurrence(&user_tz_now, false)?;
|
||||||
|
|
||||||
Ok(next)
|
let next_utc = next.with_timezone(&Utc);
|
||||||
|
|
||||||
|
Ok(next_utc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,13 +11,14 @@
|
|||||||
"codegen-watch": "graphql-codegen --config graphql-codegen.ts --watch"
|
"codegen-watch": "graphql-codegen --config graphql-codegen.ts --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"recorder": "workspace:*",
|
|
||||||
"@abraham/reflection": "^0.13.0",
|
"@abraham/reflection": "^0.13.0",
|
||||||
"@apollo/client": "^3.13.8",
|
"@apollo/client": "^3.13.8",
|
||||||
"@codemirror/language": "6.11.1",
|
"@codemirror/language": "6.11.1",
|
||||||
"@corvu/drawer": "^0.2.4",
|
"@corvu/drawer": "^0.2.4",
|
||||||
"@corvu/otp-field": "^0.1.4",
|
"@corvu/otp-field": "^0.1.4",
|
||||||
"@corvu/resizable": "^0.2.5",
|
"@corvu/resizable": "^0.2.5",
|
||||||
|
"@datasert/cronjs-matcher": "^1.4.0",
|
||||||
|
"@datasert/cronjs-parser": "^1.4.0",
|
||||||
"@graphiql/toolkit": "^0.11.3",
|
"@graphiql/toolkit": "^0.11.3",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@outposts/injection-js": "^2.5.1",
|
"@outposts/injection-js": "^2.5.1",
|
||||||
@ -72,9 +73,11 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-resizable-panels": "^3.0.2",
|
"react-resizable-panels": "^3.0.2",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
|
"recorder": "workspace:*",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"sonner": "^2.0.5",
|
"sonner": "^2.0.5",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwind-scrollbar": "^4.0.2",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"tw-animate-css": "^1.3.4",
|
"tw-animate-css": "^1.3.4",
|
||||||
"type-fest": "^4.41.0",
|
"type-fest": "^4.41.0",
|
||||||
|
@ -145,3 +145,5 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@plugin "tailwind-scrollbar";
|
||||||
|
291
apps/webui/src/components/domains/cron/README.md
Normal file
291
apps/webui/src/components/domains/cron/README.md
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
# Cron Components
|
||||||
|
|
||||||
|
A comprehensive set of React components for creating, editing, and displaying cron expressions with TypeScript support and shadcn/ui integration.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎯 **Multiple Input Modes**: Text input, visual builder, or both
|
||||||
|
- 🔍 **Real-time Validation**: Powered by `@datasert/cronjs-parser`
|
||||||
|
- ⏰ **Next Run Preview**: Shows upcoming execution times with `@datasert/cronjs-matcher`
|
||||||
|
- 🌍 **Timezone Support**: Display times in different timezones
|
||||||
|
- 📱 **Responsive Design**: Works seamlessly on desktop and mobile
|
||||||
|
- 🎨 **shadcn/ui Integration**: Consistent with your existing design system
|
||||||
|
- 🔧 **TypeScript Support**: Full type definitions included
|
||||||
|
- 🚀 **Customizable**: Extensive props for customization
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `<Cron />` - Main Component
|
||||||
|
|
||||||
|
The primary component that combines all functionality.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Cron } from '@/components/cron';
|
||||||
|
|
||||||
|
function MyScheduler() {
|
||||||
|
const [cronExpression, setCronExpression] = useState('0 0 9 * * 1-5');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Cron
|
||||||
|
value={cronExpression}
|
||||||
|
onChange={setCronExpression}
|
||||||
|
mode="both" // 'input' | 'builder' | 'both'
|
||||||
|
showPreview={true}
|
||||||
|
showDescription={true}
|
||||||
|
timezone="UTC"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `value` | `string` | `''` | Current cron expression |
|
||||||
|
| `onChange` | `(value: string) => void` | - | Called when expression changes |
|
||||||
|
| `onValidate` | `(isValid: boolean) => void` | - | Called when validation state changes |
|
||||||
|
| `mode` | `'input' \| 'builder' \| 'both'` | `'both'` | Display mode |
|
||||||
|
| `disabled` | `boolean` | `false` | Disable all inputs |
|
||||||
|
| `placeholder` | `string` | `'0 0 * * * *'` | Input placeholder text |
|
||||||
|
| `showPreview` | `boolean` | `true` | Show next run times preview |
|
||||||
|
| `showDescription` | `boolean` | `true` | Show human-readable description |
|
||||||
|
| `timezone` | `string` | `'UTC'` | Timezone for preview times |
|
||||||
|
| `error` | `string` | - | External error message |
|
||||||
|
| `className` | `ClassValue` | - | Additional CSS classes |
|
||||||
|
|
||||||
|
### `<CronInput />` - Text Input Component
|
||||||
|
|
||||||
|
Simple text input with validation and help text.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CronInput } from '@/components/cron';
|
||||||
|
|
||||||
|
function QuickEntry() {
|
||||||
|
const [expression, setExpression] = useState('');
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CronInput
|
||||||
|
value={expression}
|
||||||
|
onChange={setExpression}
|
||||||
|
onValidate={setIsValid}
|
||||||
|
placeholder="Enter cron expression..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `value` | `string` | - | Current expression value |
|
||||||
|
| `onChange` | `(value: string) => void` | - | Called when input changes |
|
||||||
|
| `onValidate` | `(isValid: boolean) => void` | - | Called when validation changes |
|
||||||
|
| `placeholder` | `string` | `'0 0 * * * *'` | Placeholder text |
|
||||||
|
| `disabled` | `boolean` | `false` | Disable input |
|
||||||
|
| `readOnly` | `boolean` | `false` | Make input read-only |
|
||||||
|
| `error` | `string` | - | Error message to display |
|
||||||
|
| `className` | `ClassValue` | - | Additional CSS classes |
|
||||||
|
|
||||||
|
### `<CronBuilder />` - Visual Builder Component
|
||||||
|
|
||||||
|
Visual interface for building cron expressions with presets and field editors.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CronBuilder } from '@/components/cron';
|
||||||
|
|
||||||
|
function VisualScheduler() {
|
||||||
|
const [expression, setExpression] = useState('0 0 * * * *');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CronBuilder
|
||||||
|
value={expression}
|
||||||
|
onChange={setExpression}
|
||||||
|
showPreview={true}
|
||||||
|
defaultTab="daily"
|
||||||
|
allowedPeriods={['hourly', 'daily', 'weekly']}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `value` | `string` | `'0 0 * * * *'` | Current expression |
|
||||||
|
| `onChange` | `(value: string) => void` | - | Called when expression changes |
|
||||||
|
| `disabled` | `boolean` | `false` | Disable all controls |
|
||||||
|
| `showPreview` | `boolean` | `true` | Show preview section |
|
||||||
|
| `defaultTab` | `CronPeriod` | `'hourly'` | Default active tab |
|
||||||
|
| `allowedPeriods` | `CronPeriod[]` | All periods | Which tabs to show |
|
||||||
|
| `presets` | `CronPreset[]` | Built-in presets | Custom preset list |
|
||||||
|
| `className` | `ClassValue` | - | Additional CSS classes |
|
||||||
|
|
||||||
|
### `<CronDisplay />` - Display Component
|
||||||
|
|
||||||
|
Read-only component for displaying cron expression information.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CronDisplay } from '@/components/cron';
|
||||||
|
|
||||||
|
function ScheduleInfo({ schedule }) {
|
||||||
|
return (
|
||||||
|
<CronDisplay
|
||||||
|
expression={schedule.cronExpression}
|
||||||
|
showNextRuns={true}
|
||||||
|
showDescription={true}
|
||||||
|
nextRunsCount={5}
|
||||||
|
timezone={schedule.timezone}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `expression` | `string` | - | Cron expression to display |
|
||||||
|
| `showNextRuns` | `boolean` | `true` | Show upcoming run times |
|
||||||
|
| `showDescription` | `boolean` | `true` | Show human-readable description |
|
||||||
|
| `nextRunsCount` | `number` | `5` | Number of future runs to show |
|
||||||
|
| `timezone` | `string` | `'UTC'` | Timezone for times |
|
||||||
|
| `className` | `ClassValue` | - | Additional CSS classes |
|
||||||
|
|
||||||
|
## Cron Expression Format
|
||||||
|
|
||||||
|
The components support 6-field cron expressions with seconds:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────── second (0-59)
|
||||||
|
│ ┌───────────── minute (0-59)
|
||||||
|
│ │ ┌─────────── hour (0-23)
|
||||||
|
│ │ │ ┌───────── day of month (1-31)
|
||||||
|
│ │ │ │ ┌─────── month (1-12)
|
||||||
|
│ │ │ │ │ ┌───── day of week (0-6, Sunday=0)
|
||||||
|
│ │ │ │ │ │
|
||||||
|
* * * * * *
|
||||||
|
```
|
||||||
|
|
||||||
|
### Special Characters
|
||||||
|
|
||||||
|
| Character | Description | Example |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `*` | Any value | `*` = every value |
|
||||||
|
| `,` | List separator | `1,3,5` = values 1, 3, and 5 |
|
||||||
|
| `-` | Range | `1-5` = values 1 through 5 |
|
||||||
|
| `/` | Step values | `*/5` = every 5th value |
|
||||||
|
| `?` | No specific value | Used when day/weekday conflict |
|
||||||
|
| `L` | Last | Last day of month/week |
|
||||||
|
| `W` | Weekday | Nearest weekday |
|
||||||
|
|
||||||
|
### Common Examples
|
||||||
|
|
||||||
|
| Expression | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `0 * * * * *` | Every minute |
|
||||||
|
| `0 */5 * * * *` | Every 5 minutes |
|
||||||
|
| `0 0 * * * *` | Every hour |
|
||||||
|
| `0 0 9 * * *` | Daily at 9 AM |
|
||||||
|
| `0 30 9 * * 1-5` | Weekdays at 9:30 AM |
|
||||||
|
| `0 0 0 * * 0` | Every Sunday at midnight |
|
||||||
|
| `0 0 0 1 * *` | First day of every month |
|
||||||
|
| `0 0 0 1 1 *` | Every January 1st |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `@datasert/cronjs-parser` - For parsing and validating cron expressions
|
||||||
|
- `@datasert/cronjs-matcher` - For calculating next run times
|
||||||
|
- `@radix-ui/react-*` - UI primitives (via shadcn/ui)
|
||||||
|
- `lucide-react` - Icons
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Copy the component files to your project
|
||||||
|
2. Ensure you have the required dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @datasert/cronjs-parser @datasert/cronjs-matcher
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Import and use the components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Cron } from '@/components/cron';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Custom Presets
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const customPresets = [
|
||||||
|
{
|
||||||
|
label: 'Business Hours',
|
||||||
|
value: '0 0 9-17 * * 1-5',
|
||||||
|
description: 'Every hour during business hours',
|
||||||
|
category: 'custom'
|
||||||
|
},
|
||||||
|
// ... more presets
|
||||||
|
];
|
||||||
|
|
||||||
|
<CronBuilder presets={customPresets} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restricted Periods
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CronBuilder
|
||||||
|
allowedPeriods={['daily', 'weekly']}
|
||||||
|
defaultTab="daily"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Validation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function MyComponent() {
|
||||||
|
const [expression, setExpression] = useState('');
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
|
||||||
|
const handleValidation = (valid: boolean) => {
|
||||||
|
setIsValid(valid);
|
||||||
|
// Custom validation logic
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Cron
|
||||||
|
value={expression}
|
||||||
|
onChange={setExpression}
|
||||||
|
onValidate={handleValidation}
|
||||||
|
error={!isValid ? 'Invalid expression' : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Support
|
||||||
|
|
||||||
|
All components include comprehensive TypeScript definitions:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type {
|
||||||
|
CronProps,
|
||||||
|
CronExpression,
|
||||||
|
CronValidationResult,
|
||||||
|
CronPeriod
|
||||||
|
} from '@/components/cron';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `CronExample` component for comprehensive usage examples and interactive demos.
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
- Modern browsers with ES2015+ support
|
||||||
|
- React 16.8+ (hooks support required)
|
||||||
|
- TypeScript 4.0+ recommended
|
643
apps/webui/src/components/domains/cron/cron-builder.tsx
Normal file
643
apps/webui/src/components/domains/cron/cron-builder.tsx
Normal file
@ -0,0 +1,643 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
|
import { cn } from '@/presentation/utils';
|
||||||
|
import { getFutureMatches } from '@datasert/cronjs-matcher';
|
||||||
|
import { Calendar, Clock, Info, Settings, Zap } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
type CSSProperties,
|
||||||
|
type FC,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
type CronBuilderProps,
|
||||||
|
CronField,
|
||||||
|
type CronFieldConfig,
|
||||||
|
CronPeriod,
|
||||||
|
type CronPreset,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const CRON_PRESETS: CronPreset[] = [
|
||||||
|
{
|
||||||
|
label: 'Every minute',
|
||||||
|
value: '0 * * * * *',
|
||||||
|
description: 'Runs every minute',
|
||||||
|
category: 'common',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every 5 minutes',
|
||||||
|
value: '0 */5 * * * *',
|
||||||
|
description: 'Runs every 5 minutes',
|
||||||
|
category: 'common',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every 15 minutes',
|
||||||
|
value: '0 */15 * * * *',
|
||||||
|
description: 'Runs every 15 minutes',
|
||||||
|
category: 'common',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every 30 minutes',
|
||||||
|
value: '0 */30 * * * *',
|
||||||
|
description: 'Runs every 30 minutes',
|
||||||
|
category: 'common',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every hour',
|
||||||
|
value: '0 0 * * * *',
|
||||||
|
description: 'Runs at the top of every hour',
|
||||||
|
category: 'common',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every 6 hours',
|
||||||
|
value: '0 0 */6 * * *',
|
||||||
|
description: 'Runs every 6 hours',
|
||||||
|
category: 'common',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Daily at midnight',
|
||||||
|
value: '0 0 0 * * *',
|
||||||
|
description: 'Runs once daily at 00:00',
|
||||||
|
category: 'daily',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Daily at 9 AM',
|
||||||
|
value: '0 0 9 * * *',
|
||||||
|
description: 'Runs daily at 9:00 AM',
|
||||||
|
category: 'daily',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Weekdays at 9 AM',
|
||||||
|
value: '0 0 9 * * 1-5',
|
||||||
|
description: 'Runs Monday to Friday at 9:00 AM',
|
||||||
|
category: 'weekly',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every Sunday',
|
||||||
|
value: '0 0 0 * * 0',
|
||||||
|
description: 'Runs every Sunday at midnight',
|
||||||
|
category: 'weekly',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'First day of month',
|
||||||
|
value: '0 0 0 1 * *',
|
||||||
|
description: 'Runs on the 1st day of every month',
|
||||||
|
category: 'monthly',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every year',
|
||||||
|
value: '0 0 0 1 1 *',
|
||||||
|
description: 'Runs on January 1st every year',
|
||||||
|
category: 'yearly',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FIELD_CONFIGS: Record<CronField, CronFieldConfig> = {
|
||||||
|
seconds: {
|
||||||
|
min: 0,
|
||||||
|
max: 59,
|
||||||
|
step: 1,
|
||||||
|
allowSpecial: ['*', '?'],
|
||||||
|
},
|
||||||
|
minutes: {
|
||||||
|
min: 0,
|
||||||
|
max: 59,
|
||||||
|
step: 1,
|
||||||
|
allowSpecial: ['*', '?'],
|
||||||
|
},
|
||||||
|
hours: {
|
||||||
|
min: 0,
|
||||||
|
max: 23,
|
||||||
|
step: 1,
|
||||||
|
allowSpecial: ['*', '?'],
|
||||||
|
},
|
||||||
|
dayOfMonth: {
|
||||||
|
min: 1,
|
||||||
|
max: 31,
|
||||||
|
step: 1,
|
||||||
|
allowSpecial: ['*', '?', 'L', 'W'],
|
||||||
|
options: [
|
||||||
|
{ label: 'Any day', value: '*' },
|
||||||
|
{ label: 'No specific day', value: '?' },
|
||||||
|
{ label: 'Last day', value: 'L' },
|
||||||
|
{ label: 'Weekday', value: 'W' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
month: {
|
||||||
|
min: 1,
|
||||||
|
max: 12,
|
||||||
|
step: 1,
|
||||||
|
allowSpecial: ['*'],
|
||||||
|
options: [
|
||||||
|
{ label: 'January', value: 1 },
|
||||||
|
{ label: 'February', value: 2 },
|
||||||
|
{ label: 'March', value: 3 },
|
||||||
|
{ label: 'April', value: 4 },
|
||||||
|
{ label: 'May', value: 5 },
|
||||||
|
{ label: 'June', value: 6 },
|
||||||
|
{ label: 'July', value: 7 },
|
||||||
|
{ label: 'August', value: 8 },
|
||||||
|
{ label: 'September', value: 9 },
|
||||||
|
{ label: 'October', value: 10 },
|
||||||
|
{ label: 'November', value: 11 },
|
||||||
|
{ label: 'December', value: 12 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dayOfWeek: {
|
||||||
|
min: 0,
|
||||||
|
max: 6,
|
||||||
|
step: 1,
|
||||||
|
allowSpecial: ['*', '?'],
|
||||||
|
options: [
|
||||||
|
{ label: 'Sunday', value: 0 },
|
||||||
|
{ label: 'Monday', value: 1 },
|
||||||
|
{ label: 'Tuesday', value: 2 },
|
||||||
|
{ label: 'Wednesday', value: 3 },
|
||||||
|
{ label: 'Thursday', value: 4 },
|
||||||
|
{ label: 'Friday', value: 5 },
|
||||||
|
{ label: 'Saturday', value: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
year: {
|
||||||
|
min: 0,
|
||||||
|
max: 9999,
|
||||||
|
step: 1,
|
||||||
|
allowSpecial: ['*', '?'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERIOD_CONFIGS = {
|
||||||
|
minute: {
|
||||||
|
label: CronPeriod.Minute,
|
||||||
|
description: 'Run every minute',
|
||||||
|
template: '0 * * * * *',
|
||||||
|
fields: [CronField.Minutes],
|
||||||
|
},
|
||||||
|
hourly: {
|
||||||
|
label: CronPeriod.Hourly,
|
||||||
|
description: 'Run every hour',
|
||||||
|
template: '0 0 * * * *',
|
||||||
|
fields: [CronField.Minutes, CronField.Hours],
|
||||||
|
},
|
||||||
|
daily: {
|
||||||
|
label: CronPeriod.Daily,
|
||||||
|
description: 'Run every day',
|
||||||
|
template: '0 0 0 * * *',
|
||||||
|
fields: [CronField.Seconds, CronField.Minutes, CronField.Hours],
|
||||||
|
},
|
||||||
|
weekly: {
|
||||||
|
label: CronPeriod.Weekly,
|
||||||
|
description: 'Run every week',
|
||||||
|
template: '0 0 0 * * 0',
|
||||||
|
fields: [
|
||||||
|
CronField.Seconds,
|
||||||
|
CronField.Minutes,
|
||||||
|
CronField.Hours,
|
||||||
|
CronField.DayOfWeek,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
monthly: {
|
||||||
|
label: CronPeriod.Monthly,
|
||||||
|
description: 'Run every month',
|
||||||
|
template: '0 0 0 1 * *',
|
||||||
|
fields: [
|
||||||
|
CronField.Seconds,
|
||||||
|
CronField.Minutes,
|
||||||
|
CronField.Hours,
|
||||||
|
CronField.DayOfMonth,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
yearly: {
|
||||||
|
label: CronPeriod.Yearly,
|
||||||
|
description: 'Run every year',
|
||||||
|
template: '0 0 0 1 1 *',
|
||||||
|
fields: [
|
||||||
|
CronField.Seconds,
|
||||||
|
CronField.Minutes,
|
||||||
|
CronField.Hours,
|
||||||
|
CronField.DayOfMonth,
|
||||||
|
CronField.Month,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
label: CronPeriod.Custom,
|
||||||
|
description: 'Custom expression',
|
||||||
|
template: '0 0 * * * *',
|
||||||
|
fields: [
|
||||||
|
CronField.Seconds,
|
||||||
|
CronField.Minutes,
|
||||||
|
CronField.Hours,
|
||||||
|
CronField.DayOfMonth,
|
||||||
|
CronField.Month,
|
||||||
|
CronField.DayOfWeek,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CronBuilder: FC<CronBuilderProps> = ({
|
||||||
|
timezone = 'UTC',
|
||||||
|
value = '0 0 * * * *',
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
showPreview = true,
|
||||||
|
showPresets = true,
|
||||||
|
displayPeriods = [
|
||||||
|
CronPeriod.Custom,
|
||||||
|
CronPeriod.Minute,
|
||||||
|
CronPeriod.Hourly,
|
||||||
|
CronPeriod.Daily,
|
||||||
|
CronPeriod.Weekly,
|
||||||
|
CronPeriod.Monthly,
|
||||||
|
CronPeriod.Yearly,
|
||||||
|
],
|
||||||
|
defaultTab = CronPeriod.Custom,
|
||||||
|
presets = CRON_PRESETS,
|
||||||
|
showGeneratedExpression = true,
|
||||||
|
withCard = true,
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<CronPeriod>(defaultTab);
|
||||||
|
const [cronFields, setCronFields] = useState(() =>
|
||||||
|
parseCronExpression(value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentExpression = useMemo(() => {
|
||||||
|
return `${cronFields.seconds} ${cronFields.minutes} ${cronFields.hours} ${cronFields.dayOfMonth} ${cronFields.month} ${cronFields.dayOfWeek}`;
|
||||||
|
}, [cronFields]);
|
||||||
|
|
||||||
|
const nextRuns = useMemo(() => {
|
||||||
|
if (!showPreview) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const matches = getFutureMatches(`${currentExpression} *`, {
|
||||||
|
matchCount: 3,
|
||||||
|
timezone,
|
||||||
|
formatInTimezone: true,
|
||||||
|
hasSeconds: true,
|
||||||
|
});
|
||||||
|
return matches.map((match) => new Date(match));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get future matched runs', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [currentExpression, showPreview, timezone]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCronFields(parseCronExpression(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange?.(currentExpression);
|
||||||
|
}, [currentExpression, onChange]);
|
||||||
|
|
||||||
|
const handlePresetSelect = useCallback((preset: CronPreset) => {
|
||||||
|
setCronFields(parseCronExpression(preset.value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFieldChange = useCallback(
|
||||||
|
(field: CronField, newValue: string) => {
|
||||||
|
setCronFields((prev) => ({ ...prev, [field]: newValue }));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePeriodChange = useCallback((period: CronPeriod) => {
|
||||||
|
setActiveTab(period);
|
||||||
|
if (period !== 'custom') {
|
||||||
|
const config = PERIOD_CONFIGS[period];
|
||||||
|
setCronFields(parseCronExpression(config.template));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredPresets = useMemo(() => {
|
||||||
|
return presets.filter((preset) => {
|
||||||
|
if (activeTab === 'custom') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return preset.category === activeTab;
|
||||||
|
});
|
||||||
|
}, [presets, activeTab]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(withCard && 'space-y-6', className)}>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => handlePeriodChange(value as CronPeriod)}
|
||||||
|
>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<TabsList
|
||||||
|
className="grid w-(--all-grids-width) grid-cols-7 whitespace-nowrap lg:w-full"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--my-grid-cols': `grid-template-columns: repeat(${displayPeriods.length}, minmax(0, 1fr))`,
|
||||||
|
'--all-grids-width':
|
||||||
|
displayPeriods.length > 4
|
||||||
|
? `${displayPeriods.length * 25 - 20}%`
|
||||||
|
: '100%',
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{displayPeriods.map((period) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={period}
|
||||||
|
value={period}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-xs capitalize"
|
||||||
|
>
|
||||||
|
{PERIOD_CONFIGS[period].label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
{displayPeriods.map((period) => (
|
||||||
|
<TabsContent
|
||||||
|
key={period}
|
||||||
|
value={period}
|
||||||
|
className={cn(withCard ? 'space-y-4' : 'px-0')}
|
||||||
|
>
|
||||||
|
<Card className={cn(!withCard && 'border-none shadow-none')}>
|
||||||
|
<CardHeader className={cn('pb-1', !withCard && 'px-0')}>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
<span className="capitalize">
|
||||||
|
{PERIOD_CONFIGS[period].label} Configuration
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{PERIOD_CONFIGS[period].description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className={cn('space-y-4', !withCard && 'px-0')}>
|
||||||
|
<CronFieldEditor
|
||||||
|
period={period}
|
||||||
|
fields={cronFields}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{showPresets && filteredPresets.length > 0 && (
|
||||||
|
<Card className={cn(!withCard && 'border-none shadow-none')}>
|
||||||
|
<CardHeader className={cn(!withCard && 'px-0')}>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Zap className="h-4 w-4" />
|
||||||
|
Quick Presets
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Common cron expressions for quick setup
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className={cn(!withCard && 'px-0')}>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{filteredPresets.map((preset, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto justify-start p-4 text-left"
|
||||||
|
onClick={() => handlePresetSelect(preset)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{preset.label}
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-normal break-words text-muted-foreground text-xs leading-relaxed">
|
||||||
|
{preset.description}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="mt-1 break-all font-mono text-xs"
|
||||||
|
>
|
||||||
|
{preset.value}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
{/* Current Expression & Preview */}
|
||||||
|
{showGeneratedExpression && (
|
||||||
|
<Card className={cn(!withCard && 'border-none shadow-none')}>
|
||||||
|
<CardHeader className={cn(!withCard && 'px-0')}>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Generated Expression
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className={cn('space-y-4', !withCard && 'px-0')}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="px-3 py-1 font-mono text-sm">
|
||||||
|
{currentExpression}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPreview && nextRuns.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Next Runs({timezone})
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{nextRuns.map((date, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between rounded bg-muted/50 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-muted-foreground">
|
||||||
|
#{index + 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="font-mono">
|
||||||
|
{date.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CronFieldEditorProps {
|
||||||
|
period: CronPeriod;
|
||||||
|
fields: Record<CronField, string>;
|
||||||
|
onChange: (field: CronField, value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CronFieldEditor: FC<CronFieldEditorProps> = ({
|
||||||
|
period,
|
||||||
|
fields,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
|
const relevantFields = [...PERIOD_CONFIGS[period].fields] as CronField[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{relevantFields.map((field) => {
|
||||||
|
const config = FIELD_CONFIGS[field];
|
||||||
|
const currentValue = fields[field];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field} className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm capitalize">
|
||||||
|
{field.replace(/([A-Z])/g, ' $1').toLowerCase()}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{field === 'month' || field === 'dayOfWeek' ? (
|
||||||
|
<Select
|
||||||
|
value={currentValue}
|
||||||
|
onValueChange={(value) => onChange(field, value)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="*">Any</SelectItem>
|
||||||
|
{config.options?.map((option, index) => (
|
||||||
|
<SelectItem key={index} value={option.value.toString()}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
// biome-ignore lint/nursery/noNestedTernary: <explanation>
|
||||||
|
) : field === 'dayOfMonth' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select
|
||||||
|
value={currentValue}
|
||||||
|
onValueChange={(value) => onChange(field, value)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{config.options?.map((option, index) => (
|
||||||
|
<SelectItem key={index} value={option.value.toString()}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => (
|
||||||
|
<SelectItem key={day} value={day.toString()}>
|
||||||
|
{day}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={currentValue === '*' ? '*' : 'specific'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === '*') {
|
||||||
|
onChange(field, '*');
|
||||||
|
} else if (value === 'specific' && currentValue === '*') {
|
||||||
|
onChange(field, '0');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<ToggleGroupItem value="*" className="min-w-fit text-xs">
|
||||||
|
Any
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem
|
||||||
|
value="specific"
|
||||||
|
className="min-w-fit text-xs"
|
||||||
|
>
|
||||||
|
Specific
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
{currentValue !== '*' && (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => onChange(field, e.target.value)}
|
||||||
|
placeholder={`0-${config.max}`}
|
||||||
|
disabled={disabled}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Info className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
Range: {config.min}-{config.max}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
Supports: *, numbers, ranges (1-5), lists (1,3,5), steps
|
||||||
|
(*/5)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseCronExpression(expression: string): Record<CronField, string> {
|
||||||
|
const parts = expression.split(' ');
|
||||||
|
|
||||||
|
// Ensure we have 6 parts, pad with defaults if needed
|
||||||
|
while (parts.length < 6) {
|
||||||
|
parts.push('*');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
seconds: parts[0] || '0',
|
||||||
|
minutes: parts[1] || '*',
|
||||||
|
hours: parts[2] || '*',
|
||||||
|
dayOfMonth: parts[3] || '*',
|
||||||
|
month: parts[4] || '*',
|
||||||
|
dayOfWeek: parts[5] || '*',
|
||||||
|
year: parts[6] || '*',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CronBuilder };
|
277
apps/webui/src/components/domains/cron/cron-display.tsx
Normal file
277
apps/webui/src/components/domains/cron/cron-display.tsx
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { cn } from '@/presentation/utils';
|
||||||
|
import { getFutureMatches, isTimeMatches } from '@datasert/cronjs-matcher';
|
||||||
|
import { parse } from '@datasert/cronjs-parser';
|
||||||
|
import { AlertCircle, CalendarDays, CheckCircle, Clock } from 'lucide-react';
|
||||||
|
import { type FC, useMemo } from 'react';
|
||||||
|
import type {
|
||||||
|
CronDisplayProps,
|
||||||
|
CronNextRun,
|
||||||
|
CronValidationResult,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const CronDisplay: FC<CronDisplayProps> = ({
|
||||||
|
expression,
|
||||||
|
className,
|
||||||
|
showNextRuns = true,
|
||||||
|
nextRunsCount = 5,
|
||||||
|
timezone = 'UTC',
|
||||||
|
showDescription = true,
|
||||||
|
withCard = true,
|
||||||
|
}) => {
|
||||||
|
const validationResult = useMemo((): CronValidationResult => {
|
||||||
|
if (!expression) {
|
||||||
|
return { isValid: false, error: 'No expression provided' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const _parsed = parse(`${expression} *`, { hasSeconds: true });
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
description: generateDescription(expression),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Invalid expression',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [expression]);
|
||||||
|
|
||||||
|
const nextRuns = useMemo((): CronNextRun[] => {
|
||||||
|
if (!expression || !validationResult.isValid || !showNextRuns) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const matches = getFutureMatches(`${expression} *`, {
|
||||||
|
matchCount: nextRunsCount,
|
||||||
|
timezone,
|
||||||
|
formatInTimezone: true,
|
||||||
|
hasSeconds: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return matches.map((match) => {
|
||||||
|
const date = new Date(match);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
timestamp: date.getTime(),
|
||||||
|
formatted: date.toLocaleString(),
|
||||||
|
relative: getRelativeTime(date),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to get future matches:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
expression,
|
||||||
|
validationResult.isValid,
|
||||||
|
showNextRuns,
|
||||||
|
nextRunsCount,
|
||||||
|
timezone,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isCurrentTimeMatch = useMemo(() => {
|
||||||
|
if (!expression || !validationResult.isValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return isTimeMatches(
|
||||||
|
`${expression} *`,
|
||||||
|
new Date().toISOString(),
|
||||||
|
timezone
|
||||||
|
);
|
||||||
|
} catch (_error: unknown) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [expression, validationResult.isValid, timezone]);
|
||||||
|
|
||||||
|
if (!expression) {
|
||||||
|
return (
|
||||||
|
<Card className={cn(className, !withCard && 'border-none shadow-none')}>
|
||||||
|
<CardContent className={cn('p-4', !withCard && 'px-0')}>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span className="text-sm">No cron expression set</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn(className, !withCard && 'border-none shadow-none')}>
|
||||||
|
<CardHeader className={cn(!withCard && 'px-0')}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Cron Expression
|
||||||
|
{isCurrentTimeMatch && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
<CheckCircle className="mr-1 h-3 w-3" />
|
||||||
|
Active Now
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<Badge
|
||||||
|
variant={validationResult.isValid ? 'secondary' : 'destructive'}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
|
{expression}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationResult.isValid &&
|
||||||
|
showDescription &&
|
||||||
|
validationResult.description && (
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
{validationResult.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!validationResult.isValid && validationResult.error && (
|
||||||
|
<CardDescription className="flex items-center gap-2 text-destructive text-sm">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
{validationResult.error}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{validationResult.isValid && showNextRuns && nextRuns.length > 0 && (
|
||||||
|
<CardContent className={cn('pt-0', !withCard && 'px-0')}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="flex items-center gap-2 font-medium text-sm">
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
Next Runs
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{timezone}
|
||||||
|
</Badge>
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{nextRuns.map((run, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between rounded border bg-muted/50 p-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-6 font-medium text-muted-foreground text-xs">
|
||||||
|
#{index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm">{run.formatted}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{run.relative}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateDescription(expression: string): string {
|
||||||
|
// Enhanced description generator based on common patterns
|
||||||
|
const parts = expression.split(' ');
|
||||||
|
if (parts.length !== 6) {
|
||||||
|
return expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sec, min, hour, day, month, weekday] = parts;
|
||||||
|
|
||||||
|
// Common patterns
|
||||||
|
const patterns: Record<string, string> = {
|
||||||
|
'* * * * * *': 'Every second',
|
||||||
|
'0 * * * * *': 'Every minute',
|
||||||
|
'0 0 * * * *': 'Every hour',
|
||||||
|
'0 0 0 * * *': 'Daily at midnight',
|
||||||
|
'0 0 0 * * 0': 'Every Sunday at midnight',
|
||||||
|
'0 0 0 * * 1': 'Every Monday at midnight',
|
||||||
|
'0 0 0 * * 2': 'Every Tuesday at midnight',
|
||||||
|
'0 0 0 * * 3': 'Every Wednesday at midnight',
|
||||||
|
'0 0 0 * * 4': 'Every Thursday at midnight',
|
||||||
|
'0 0 0 * * 5': 'Every Friday at midnight',
|
||||||
|
'0 0 0 * * 6': 'Every Saturday at midnight',
|
||||||
|
'0 0 0 1 * *': 'Monthly on the 1st at midnight',
|
||||||
|
'0 0 0 1 1 *': 'Yearly on January 1st at midnight',
|
||||||
|
'0 30 9 * * 1-5': 'Weekdays at 9:30 AM',
|
||||||
|
'0 0 */6 * * *': 'Every 6 hours',
|
||||||
|
'0 */30 * * * *': 'Every 30 minutes',
|
||||||
|
'0 */15 * * * *': 'Every 15 minutes',
|
||||||
|
'0 */5 * * * *': 'Every 5 minutes',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (patterns[expression]) {
|
||||||
|
return patterns[expression];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate dynamic description
|
||||||
|
let description = 'At ';
|
||||||
|
|
||||||
|
if (sec !== '*' && sec !== '0') {
|
||||||
|
description += `second ${sec}, `;
|
||||||
|
}
|
||||||
|
if (min !== '*') {
|
||||||
|
description += `minute ${min}, `;
|
||||||
|
}
|
||||||
|
if (hour !== '*') {
|
||||||
|
description += `hour ${hour}, `;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (day !== '*' && weekday !== '*') {
|
||||||
|
description += `on day ${day} and weekday ${weekday} `;
|
||||||
|
} else if (day !== '*') {
|
||||||
|
description += `on day ${day} `;
|
||||||
|
} else if (weekday !== '*') {
|
||||||
|
description += `on weekday ${weekday} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (month !== '*') {
|
||||||
|
description += `in month ${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// biome-ignore lint/performance/useTopLevelRegex: <explanation>
|
||||||
|
return description.replace(/,\s*$/, '').replace(/At\s*$/, 'Every occurrence');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelativeTime(date: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = date.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (diffMs < 0) {
|
||||||
|
return 'Past';
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffSec = Math.floor(diffMs / 1000);
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHour / 24);
|
||||||
|
|
||||||
|
if (diffSec < 60) {
|
||||||
|
return `in ${diffSec}s`;
|
||||||
|
}
|
||||||
|
if (diffMin < 60) {
|
||||||
|
return `in ${diffMin}m`;
|
||||||
|
}
|
||||||
|
if (diffHour < 24) {
|
||||||
|
return `in ${diffHour}h`;
|
||||||
|
}
|
||||||
|
if (diffDay < 7) {
|
||||||
|
return `in ${diffDay}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `in ${Math.floor(diffDay / 7)}w`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CronDisplay };
|
413
apps/webui/src/components/domains/cron/cron-example.tsx
Normal file
413
apps/webui/src/components/domains/cron/cron-example.tsx
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Code2, Play, Settings, Type } from 'lucide-react';
|
||||||
|
import { type FC, useCallback, useState } from 'react';
|
||||||
|
import { CronBuilder } from './cron-builder.js';
|
||||||
|
import { CronDisplay } from './cron-display.js';
|
||||||
|
import { CronInput } from './cron-input.js';
|
||||||
|
import { Cron } from './cron.js';
|
||||||
|
|
||||||
|
const CronExample: FC = () => {
|
||||||
|
const [inputValue, setInputValue] = useState('0 30 9 * * 1-5');
|
||||||
|
const [builderValue, setBuilderValue] = useState('0 0 */6 * * *');
|
||||||
|
const [fullValue, setFullValue] = useState('0 */15 * * * *');
|
||||||
|
const [displayValue] = useState('0 0 0 * * 0');
|
||||||
|
|
||||||
|
const examples = [
|
||||||
|
{
|
||||||
|
label: 'Every minute',
|
||||||
|
expression: '0 * * * * *',
|
||||||
|
description: 'Runs at the start of every minute',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every 5 minutes',
|
||||||
|
expression: '0 */5 * * * *',
|
||||||
|
description: 'Runs every 5 minutes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every hour',
|
||||||
|
expression: '0 0 * * * *',
|
||||||
|
description: 'Runs at the start of every hour',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Daily at 9 AM',
|
||||||
|
expression: '0 0 9 * * *',
|
||||||
|
description: 'Runs every day at 9:00 AM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Weekdays at 9:30 AM',
|
||||||
|
expression: '0 30 9 * * 1-5',
|
||||||
|
description: 'Runs Monday through Friday at 9:30 AM',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every Sunday',
|
||||||
|
expression: '0 0 0 * * 0',
|
||||||
|
description: 'Runs every Sunday at midnight',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'First day of month',
|
||||||
|
expression: '0 0 0 1 * *',
|
||||||
|
description: 'Runs on the 1st day of every month',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every quarter',
|
||||||
|
expression: '0 0 0 1 */3 *',
|
||||||
|
description: 'Runs on the 1st day of every quarter',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleCopyExample = useCallback(async (expression: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(expression);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to copy to clipboard:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="font-bold text-3xl">Cron Expression Components</h1>
|
||||||
|
<p className="text-lg text-muted-foreground">
|
||||||
|
A comprehensive set of components for creating and managing cron
|
||||||
|
expressions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Examples */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Code2 className="h-5 w-5" />
|
||||||
|
Common Examples
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Click any example to copy the expression to your clipboard
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{examples.map((example, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto flex-col items-start p-4 text-left"
|
||||||
|
onClick={() => handleCopyExample(example.expression)}
|
||||||
|
>
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<div className="font-medium text-sm">{example.label}</div>
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
{example.expression}
|
||||||
|
</Badge>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{example.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Component Examples */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="font-semibold text-2xl">Component Examples</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Interactive examples showing different ways to use the cron
|
||||||
|
components.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="full" className="space-y-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
|
<TabsTrigger value="full">Complete</TabsTrigger>
|
||||||
|
<TabsTrigger value="input">Input Only</TabsTrigger>
|
||||||
|
<TabsTrigger value="builder">Builder Only</TabsTrigger>
|
||||||
|
<TabsTrigger value="display">Display Only</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="full" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
Complete Cron Component
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Full-featured component with both input and visual builder
|
||||||
|
modes, validation, preview, and help documentation.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Cron
|
||||||
|
value={fullValue}
|
||||||
|
onChange={setFullValue}
|
||||||
|
mode="both"
|
||||||
|
showPreview={true}
|
||||||
|
showDescription={true}
|
||||||
|
timezone="UTC"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded bg-muted p-4">
|
||||||
|
<h4 className="mb-2 font-medium text-sm">Current Value:</h4>
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
{fullValue || 'No expression set'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="input" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Type className="h-5 w-5" />
|
||||||
|
Text Input Component
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Simple text input with validation, help text, and real-time
|
||||||
|
feedback.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CronInput
|
||||||
|
value={inputValue}
|
||||||
|
onChange={setInputValue}
|
||||||
|
placeholder="Enter cron expression..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded bg-muted p-4">
|
||||||
|
<h4 className="mb-2 font-medium text-sm">Current Value:</h4>
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
{inputValue || 'No expression set'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Input-Only Mode</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Using the main Cron component in input-only mode with preview.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Cron
|
||||||
|
value={inputValue}
|
||||||
|
onChange={setInputValue}
|
||||||
|
mode="input"
|
||||||
|
showPreview={true}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="builder" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
Visual Builder Component
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Visual interface for building cron expressions with presets
|
||||||
|
and field editors.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CronBuilder
|
||||||
|
value={builderValue}
|
||||||
|
onChange={setBuilderValue}
|
||||||
|
showPreview={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded bg-muted p-4">
|
||||||
|
<h4 className="mb-2 font-medium text-sm">Current Value:</h4>
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
{builderValue || 'No expression set'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Builder-Only Mode</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Using the main Cron component in builder-only mode.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Cron
|
||||||
|
value={builderValue}
|
||||||
|
onChange={setBuilderValue}
|
||||||
|
mode="builder"
|
||||||
|
showPreview={false}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="display" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Play className="h-5 w-5" />
|
||||||
|
Display Component
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Read-only component that shows cron expression details,
|
||||||
|
description, and next run times.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CronDisplay
|
||||||
|
expression={displayValue}
|
||||||
|
showNextRuns={true}
|
||||||
|
showDescription={true}
|
||||||
|
nextRunsCount={5}
|
||||||
|
timezone="UTC"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Multiple Timezone Display</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Same expression displayed in different timezones.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium text-sm">UTC</h4>
|
||||||
|
<CronDisplay
|
||||||
|
expression="0 0 12 * * *"
|
||||||
|
showNextRuns={true}
|
||||||
|
nextRunsCount={3}
|
||||||
|
timezone="UTC"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium text-sm">
|
||||||
|
America/New_York
|
||||||
|
</h4>
|
||||||
|
<CronDisplay
|
||||||
|
expression="0 0 12 * * *"
|
||||||
|
showNextRuns={true}
|
||||||
|
nextRunsCount={3}
|
||||||
|
timezone="America/New_York"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Examples */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Usage Examples</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Code examples showing how to integrate these components into your
|
||||||
|
application.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium text-sm">Basic Usage</h4>
|
||||||
|
<div className="rounded bg-muted p-4 font-mono text-sm">
|
||||||
|
<pre>{`import { Cron } from '@/components/cron';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const [cronExpression, setCronExpression] = useState('0 0 * * * *');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Cron
|
||||||
|
value={cronExpression}
|
||||||
|
onChange={setCronExpression}
|
||||||
|
mode="both"
|
||||||
|
showPreview={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium text-sm">
|
||||||
|
Input Only with Validation
|
||||||
|
</h4>
|
||||||
|
<div className="rounded bg-muted p-4 font-mono text-sm">
|
||||||
|
<pre>{`import { CronInput } from '@/components/cron';
|
||||||
|
|
||||||
|
function ScheduleForm() {
|
||||||
|
const [expression, setExpression] = useState('');
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CronInput
|
||||||
|
value={expression}
|
||||||
|
onChange={setExpression}
|
||||||
|
onValidate={setIsValid}
|
||||||
|
placeholder="0 0 * * * *"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 font-medium text-sm">
|
||||||
|
Display Schedule Information
|
||||||
|
</h4>
|
||||||
|
<div className="rounded bg-muted p-4 font-mono text-sm">
|
||||||
|
<pre>{`import { CronDisplay } from '@/components/cron';
|
||||||
|
|
||||||
|
function SchedulePreview({ schedule }) {
|
||||||
|
return (
|
||||||
|
<CronDisplay
|
||||||
|
expression={schedule.cronExpression}
|
||||||
|
showNextRuns={true}
|
||||||
|
showDescription={true}
|
||||||
|
timezone={schedule.timezone}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}`}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { CronExample };
|
186
apps/webui/src/components/domains/cron/cron-input.tsx
Normal file
186
apps/webui/src/components/domains/cron/cron-input.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { cn } from '@/presentation/utils';
|
||||||
|
import { parse } from '@datasert/cronjs-parser';
|
||||||
|
import { AlertCircle, CheckCircle, Info } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
type ChangeEvent,
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import type { CronInputProps, CronValidationResult } from './types.js';
|
||||||
|
|
||||||
|
const CronInput = forwardRef<HTMLInputElement, CronInputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onValidate,
|
||||||
|
placeholder = '0 0 * * * *',
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
readOnly,
|
||||||
|
error,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [internalValue, setInternalValue] = useState(value || '');
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
|
const validationResult = useMemo((): CronValidationResult => {
|
||||||
|
if (!internalValue.trim()) {
|
||||||
|
return { isValid: false, error: 'Expression is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
parse(`${internalValue} *`, { hasSeconds: true });
|
||||||
|
return { isValid: true };
|
||||||
|
} catch (parseError) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error:
|
||||||
|
parseError instanceof Error
|
||||||
|
? parseError.message
|
||||||
|
: 'Invalid cron expression',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [internalValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInternalValue(value || '');
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onValidate?.(validationResult.isValid);
|
||||||
|
}, [validationResult.isValid, onValidate]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setInternalValue(newValue);
|
||||||
|
onChange?.(newValue);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = useCallback(() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasError =
|
||||||
|
error || (!validationResult.isValid && internalValue.trim());
|
||||||
|
const showSuccess =
|
||||||
|
validationResult.isValid && internalValue.trim() && !isFocused;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type="text"
|
||||||
|
value={internalValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn(
|
||||||
|
'pr-10 font-mono text-sm',
|
||||||
|
hasError && 'border-destructive focus-visible:ring-destructive',
|
||||||
|
showSuccess && 'border-success focus-visible:ring-success',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
readOnly={readOnly}
|
||||||
|
aria-invalid={hasError ? 'true' : 'false'}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Status icon */}
|
||||||
|
<div className="-translate-y-1/2 absolute top-1/2 right-3">
|
||||||
|
{hasError && <AlertCircle className="h-4 w-4 text-destructive" />}
|
||||||
|
{showSuccess && <CheckCircle className="h-4 w-4 text-success" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{hasError && (
|
||||||
|
<div className="flex items-center gap-2 text-destructive text-sm">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>{error || validationResult.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Help text when focused */}
|
||||||
|
{isFocused && !hasError && (
|
||||||
|
<div className="space-y-2 text-muted-foreground text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<span>Format: second minute hour day month weekday</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
*
|
||||||
|
</Badge>
|
||||||
|
<span>any value</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
,
|
||||||
|
</Badge>
|
||||||
|
<span>list separator</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
-
|
||||||
|
</Badge>
|
||||||
|
<span>range</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
/
|
||||||
|
</Badge>
|
||||||
|
<span>step value</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="font-medium text-xs">Examples:</div>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
0 * * * * *
|
||||||
|
</Badge>
|
||||||
|
<span>Every minute</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
0 0 * * * *
|
||||||
|
</Badge>
|
||||||
|
<span>Every hour</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
0 30 9 * * 1-5
|
||||||
|
</Badge>
|
||||||
|
<span>Weekdays at 9:30 AM</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
CronInput.displayName = 'CronInput';
|
||||||
|
|
||||||
|
export { CronInput };
|
500
apps/webui/src/components/domains/cron/cron.tsx
Normal file
500
apps/webui/src/components/domains/cron/cron.tsx
Normal file
@ -0,0 +1,500 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { cn } from '@/presentation/utils';
|
||||||
|
import { parse } from '@datasert/cronjs-parser';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Bolt,
|
||||||
|
Check,
|
||||||
|
Code2,
|
||||||
|
Copy,
|
||||||
|
Settings,
|
||||||
|
Type,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { type FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { CronBuilder } from './cron-builder';
|
||||||
|
import { CronDisplay } from './cron-display';
|
||||||
|
import { CronInput } from './cron-input';
|
||||||
|
import {
|
||||||
|
CronMode,
|
||||||
|
type CronPrimitiveMode,
|
||||||
|
type CronProps,
|
||||||
|
type CronValidationResult,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const PLACEHOLDER = '0 0 * * * *';
|
||||||
|
|
||||||
|
const Cron: FC<CronProps> = ({
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
activeMode = 'input',
|
||||||
|
onActiveModeChange,
|
||||||
|
onValidate,
|
||||||
|
className,
|
||||||
|
mode = 'both',
|
||||||
|
disabled = false,
|
||||||
|
placeholder = PLACEHOLDER,
|
||||||
|
showPreview = true,
|
||||||
|
showDescription = true,
|
||||||
|
timezone = 'UTC',
|
||||||
|
error,
|
||||||
|
children,
|
||||||
|
showHelp = true,
|
||||||
|
displayPeriods,
|
||||||
|
defaultTab,
|
||||||
|
presets,
|
||||||
|
showPresets,
|
||||||
|
withCard = true,
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: <explanation>
|
||||||
|
}) => {
|
||||||
|
const [internalValue, setInternalValue] = useState(value || '');
|
||||||
|
const [internalActiveMode, setInternalActiveMode] =
|
||||||
|
useState<CronPrimitiveMode>(
|
||||||
|
mode === CronMode.Both ? activeMode : (mode as CronPrimitiveMode)
|
||||||
|
);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const validationResult = useMemo((): CronValidationResult => {
|
||||||
|
if (!internalValue.trim()) {
|
||||||
|
return { isValid: false, error: 'Expression is required', isEmpty: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
parse(`${internalValue} *`, { hasSeconds: true });
|
||||||
|
return { isValid: true };
|
||||||
|
} catch (parseError) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error:
|
||||||
|
parseError instanceof Error
|
||||||
|
? parseError.message
|
||||||
|
: 'Invalid cron expression',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [internalValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInternalValue(value || '');
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onValidate?.(validationResult.isValid);
|
||||||
|
}, [validationResult.isValid, onValidate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'both') {
|
||||||
|
setInternalActiveMode(activeMode);
|
||||||
|
}
|
||||||
|
}, [activeMode, mode]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(newValue: string) => {
|
||||||
|
setInternalValue(newValue);
|
||||||
|
onChange?.(newValue);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleActiveModeChange = useCallback(
|
||||||
|
(mode: CronPrimitiveMode) => {
|
||||||
|
setInternalActiveMode(mode);
|
||||||
|
onActiveModeChange?.(mode);
|
||||||
|
},
|
||||||
|
[onActiveModeChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
if (!internalValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(internalValue);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to copy to clipboard:', error);
|
||||||
|
}
|
||||||
|
}, [internalValue]);
|
||||||
|
|
||||||
|
const hasError =
|
||||||
|
!!error || !!(!validationResult.isValid && internalValue.trim());
|
||||||
|
|
||||||
|
if (mode === 'input') {
|
||||||
|
return (
|
||||||
|
<div className={cn(withCard && 'space-y-4', className)}>
|
||||||
|
<CronInput
|
||||||
|
value={internalValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onValidate={onValidate}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showPreview &&
|
||||||
|
(validationResult.isValid || validationResult.isEmpty) && (
|
||||||
|
<CronDisplay
|
||||||
|
expression={
|
||||||
|
validationResult.isEmpty ? placeholder : internalValue
|
||||||
|
}
|
||||||
|
showNextRuns={true}
|
||||||
|
showDescription={showDescription}
|
||||||
|
timezone={timezone}
|
||||||
|
nextRunsCount={3}
|
||||||
|
withCard={withCard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'builder') {
|
||||||
|
return (
|
||||||
|
<div className={cn(withCard && 'space-y-4', className)}>
|
||||||
|
<CronBuilder
|
||||||
|
value={internalValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
showPreview={showPreview}
|
||||||
|
displayPeriods={displayPeriods}
|
||||||
|
defaultTab={defaultTab}
|
||||||
|
presets={presets}
|
||||||
|
showPresets={showPresets}
|
||||||
|
showGeneratedExpression={true}
|
||||||
|
timezone={timezone}
|
||||||
|
withCard={withCard}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(withCard && 'space-y-6', className)}>
|
||||||
|
<Card className={cn(!withCard && 'border-none pt-0 shadow-none')}>
|
||||||
|
<CardHeader className={cn(!withCard && 'px-0')}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Bolt className="h-4 w-4" />
|
||||||
|
Cron Expression Builder
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Create and validate cron expressions using visual builder or
|
||||||
|
text input
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{internalValue && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
validationResult.isValid ? 'secondary' : 'destructive'
|
||||||
|
}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
>
|
||||||
|
{internalValue}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={!internalValue || hasError}
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasError && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-destructive text-sm">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>{error || validationResult.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className={cn(!withCard && 'px-0')}>
|
||||||
|
<Tabs
|
||||||
|
value={internalActiveMode}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleActiveModeChange(value as 'input' | 'builder')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="input" className="flex items-center gap-2">
|
||||||
|
<Type className="h-4 w-4" />
|
||||||
|
Text Input
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="builder" className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Visual Builder
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="input" className="mt-6 space-y-4">
|
||||||
|
<CronInput
|
||||||
|
value={internalValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onValidate={onValidate}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="builder" className="mt-6">
|
||||||
|
<CronBuilder
|
||||||
|
value={internalValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={disabled}
|
||||||
|
showPreview={false}
|
||||||
|
displayPeriods={displayPeriods}
|
||||||
|
defaultTab={defaultTab}
|
||||||
|
presets={presets}
|
||||||
|
showPresets={showPresets}
|
||||||
|
showGeneratedExpression={false}
|
||||||
|
timezone={timezone}
|
||||||
|
withCard={withCard}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Preview Section */}
|
||||||
|
{showPreview &&
|
||||||
|
(validationResult.isValid || validationResult.isEmpty) && (
|
||||||
|
<>
|
||||||
|
{!withCard && <Separator />}
|
||||||
|
<CronDisplay
|
||||||
|
expression={
|
||||||
|
validationResult.isEmpty ? placeholder : internalValue
|
||||||
|
}
|
||||||
|
showNextRuns={true}
|
||||||
|
showDescription={showDescription}
|
||||||
|
timezone={timezone}
|
||||||
|
nextRunsCount={3}
|
||||||
|
withCard={withCard}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Help Section */}
|
||||||
|
{showHelp && (
|
||||||
|
<>
|
||||||
|
{!withCard && <Separator />}
|
||||||
|
<Card className={cn(!withCard && 'border-none shadow-none')}>
|
||||||
|
<CardHeader className={cn(!withCard && 'px-0')}>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Code2 className="h-4 w-4" />
|
||||||
|
Cron Expression Format
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className={cn(!withCard && 'px-0')}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-6 gap-2 text-center text-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium font-mono text-muted-foreground">
|
||||||
|
Second
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">0-59</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium font-mono text-muted-foreground">
|
||||||
|
Minute
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">0-59</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium font-mono text-muted-foreground">
|
||||||
|
Hour
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">0-23</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium font-mono text-muted-foreground">
|
||||||
|
Day
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">1-31</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium font-mono text-muted-foreground">
|
||||||
|
Month
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">1-12</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium font-mono text-muted-foreground">
|
||||||
|
Weekday
|
||||||
|
</div>
|
||||||
|
<div className="text-xs">0-6</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
*
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">Any value</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Matches all possible values
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
5
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">Specific value</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Matches exactly this value
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
1-5
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">Range</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Matches values 1 through 5
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
1,3,5
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">List</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Matches values 1, 3, and 5
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
*/5
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">Step</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Every 5th value
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
0-10/2
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">Range + Step</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Even values 0-10
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
?
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">No specific</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Used when day/weekday conflicts
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
L
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm">Last</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
Last day of month/week
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">Common Examples:</h4>
|
||||||
|
<div className="grid gap-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
0 0 * * * *
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground">Every hour</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
0 */15 * * * *
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Every 15 minutes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
0 0 0 * * *
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Daily at midnight
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
0 30 9 * * 1-5
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Weekdays at 9:30 AM
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Cron };
|
20
apps/webui/src/components/domains/cron/index.ts
Normal file
20
apps/webui/src/components/domains/cron/index.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export { Cron } from './cron';
|
||||||
|
export { CronInput } from './cron-input';
|
||||||
|
export { CronBuilder } from './cron-builder';
|
||||||
|
export { CronDisplay } from './cron-display';
|
||||||
|
export { CronExample } from './cron-example';
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CronProps,
|
||||||
|
type CronInputProps,
|
||||||
|
type CronBuilderProps,
|
||||||
|
type CronDisplayProps,
|
||||||
|
type CronExpression,
|
||||||
|
CronPeriod,
|
||||||
|
type CronPreset,
|
||||||
|
type CronValidationResult,
|
||||||
|
type CronNextRun,
|
||||||
|
type CronFieldConfig,
|
||||||
|
CronField,
|
||||||
|
type PeriodConfig,
|
||||||
|
} from './types';
|
162
apps/webui/src/components/domains/cron/types.ts
Normal file
162
apps/webui/src/components/domains/cron/types.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import type { ClassValue } from 'clsx';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface CronExpression {
|
||||||
|
seconds?: string;
|
||||||
|
minutes?: string;
|
||||||
|
hours?: string;
|
||||||
|
dayOfMonth?: string;
|
||||||
|
month?: string;
|
||||||
|
dayOfWeek?: string;
|
||||||
|
year?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronDisplayProps {
|
||||||
|
expression: string;
|
||||||
|
className?: ClassValue;
|
||||||
|
showNextRuns?: boolean;
|
||||||
|
nextRunsCount?: number;
|
||||||
|
timezone?: string;
|
||||||
|
showDescription?: boolean;
|
||||||
|
withCard?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronInputProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
onValidate?: (isValid: boolean) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: ClassValue;
|
||||||
|
disabled?: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronBuilderProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
className?: ClassValue;
|
||||||
|
disabled?: boolean;
|
||||||
|
showPreview?: boolean;
|
||||||
|
defaultTab?: CronPeriod;
|
||||||
|
displayPeriods?: CronPeriod[];
|
||||||
|
presets?: CronPreset[];
|
||||||
|
showPresets?: boolean;
|
||||||
|
showGeneratedExpression?: boolean;
|
||||||
|
timezone?: string;
|
||||||
|
withCard?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CronPrimitiveMode = {
|
||||||
|
Input: 'input',
|
||||||
|
Builder: 'builder',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CronPrimitiveMode =
|
||||||
|
(typeof CronPrimitiveMode)[keyof typeof CronPrimitiveMode];
|
||||||
|
|
||||||
|
export const CronMode = {
|
||||||
|
Input: 'input',
|
||||||
|
Builder: 'builder',
|
||||||
|
Both: 'both',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CronMode = (typeof CronMode)[keyof typeof CronMode];
|
||||||
|
|
||||||
|
export interface CronProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
activeMode?: CronPrimitiveMode;
|
||||||
|
onActiveModeChange?: (mode: CronPrimitiveMode) => void;
|
||||||
|
onValidate?: (isValid: boolean) => void;
|
||||||
|
className?: ClassValue;
|
||||||
|
mode?: CronMode;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
showPreview?: boolean;
|
||||||
|
showDescription?: boolean;
|
||||||
|
timezone?: string;
|
||||||
|
error?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
defaultTab?: CronPeriod;
|
||||||
|
displayPeriods?: CronPeriod[];
|
||||||
|
presets?: CronPreset[];
|
||||||
|
showHelp?: boolean;
|
||||||
|
showPresets?: boolean;
|
||||||
|
withCard?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CronPeriod = {
|
||||||
|
Minute: 'minute',
|
||||||
|
Hourly: 'hourly',
|
||||||
|
Daily: 'daily',
|
||||||
|
Weekly: 'weekly',
|
||||||
|
Monthly: 'monthly',
|
||||||
|
Yearly: 'yearly',
|
||||||
|
Custom: 'custom',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CronPeriod = (typeof CronPeriod)[keyof typeof CronPeriod];
|
||||||
|
|
||||||
|
export interface CronFieldProps {
|
||||||
|
period: CronPeriod;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: ClassValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronPreset {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
description?: string;
|
||||||
|
isEmpty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronNextRun {
|
||||||
|
date: Date;
|
||||||
|
timestamp: number;
|
||||||
|
formatted: string;
|
||||||
|
relative: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodConfig {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
defaultValue: string;
|
||||||
|
fields: {
|
||||||
|
seconds?: boolean;
|
||||||
|
minutes?: boolean;
|
||||||
|
hours?: boolean;
|
||||||
|
dayOfMonth?: boolean;
|
||||||
|
month?: boolean;
|
||||||
|
dayOfWeek?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CronField = {
|
||||||
|
Seconds: 'seconds',
|
||||||
|
Minutes: 'minutes',
|
||||||
|
Hours: 'hours',
|
||||||
|
DayOfMonth: 'dayOfMonth',
|
||||||
|
Month: 'month',
|
||||||
|
DayOfWeek: 'dayOfWeek',
|
||||||
|
Year: 'year',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CronField = (typeof CronField)[keyof typeof CronField];
|
||||||
|
|
||||||
|
export interface CronFieldConfig {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step?: number;
|
||||||
|
options?: Array<{ label: string; value: number | string }>;
|
||||||
|
allowSpecial?: string[];
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { Card, CardContent, CardHeader } from './ui/card';
|
import { Card, CardContent, CardHeader } from "./card";
|
||||||
import { Skeleton } from './ui/skeleton';
|
import { Skeleton } from "./skeleton";
|
||||||
|
|
||||||
export function DetailCardSkeleton() {
|
export function DetailCardSkeleton() {
|
||||||
return (
|
return (
|
@ -1,3 +1,4 @@
|
|||||||
|
import type { CronPreset } from '@/components/domains/cron';
|
||||||
import type { GetCronsQuery } from '@/infra/graphql/gql/graphql';
|
import type { GetCronsQuery } from '@/infra/graphql/gql/graphql';
|
||||||
import { gql } from '@apollo/client';
|
import { gql } from '@apollo/client';
|
||||||
|
|
||||||
@ -56,3 +57,64 @@ export const DELETE_CRONS = gql`
|
|||||||
cronDelete(filter: $filter)
|
cronDelete(filter: $filter)
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const INSERT_CRON = gql`
|
||||||
|
mutation InsertCron($data: CronInsertInput!) {
|
||||||
|
cronCreateOne(data: $data) {
|
||||||
|
id
|
||||||
|
cronExpr
|
||||||
|
nextRun
|
||||||
|
lastRun
|
||||||
|
lastError
|
||||||
|
status
|
||||||
|
lockedAt
|
||||||
|
lockedBy
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
timeoutMs
|
||||||
|
maxAttempts
|
||||||
|
priority
|
||||||
|
attempts
|
||||||
|
subscriberTaskCron
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SUBSCRIPTION_TASK_CRON_PRESETS: CronPreset[] = [
|
||||||
|
{
|
||||||
|
label: 'Daily at midnight',
|
||||||
|
value: '0 0 0 * * *',
|
||||||
|
description: 'Runs once daily at 00:00',
|
||||||
|
category: 'daily',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Daily at 9 AM',
|
||||||
|
value: '0 0 9 * * *',
|
||||||
|
description: 'Runs daily at 9:00 AM',
|
||||||
|
category: 'daily',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Weekdays at 9 AM',
|
||||||
|
value: '0 0 9 * * 1-5',
|
||||||
|
description: 'Runs Monday to Friday at 9:00 AM',
|
||||||
|
category: 'weekly',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every Sunday',
|
||||||
|
value: '0 0 0 * * 0',
|
||||||
|
description: 'Runs every Sunday at midnight',
|
||||||
|
category: 'weekly',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'First day of month',
|
||||||
|
value: '0 0 0 1 * *',
|
||||||
|
description: 'Runs on the 1st day of every month',
|
||||||
|
category: 'monthly',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every year',
|
||||||
|
value: '0 0 0 1 1 *',
|
||||||
|
description: 'Runs on January 1st every year',
|
||||||
|
category: 'yearly',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
@ -22,6 +22,7 @@ type Documents = {
|
|||||||
"\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n": typeof types.CheckCredential3rdAvailableDocument,
|
"\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n": typeof types.CheckCredential3rdAvailableDocument,
|
||||||
"\nquery GetCrons($filter: CronFilterInput!, $orderBy: CronOrderInput!, $pagination: PaginationInput!) {\n cron(pagination: $pagination, filter: $filter, orderBy: $orderBy) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n subscriberTask {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetCronsDocument,
|
"\nquery GetCrons($filter: CronFilterInput!, $orderBy: CronOrderInput!, $pagination: PaginationInput!) {\n cron(pagination: $pagination, filter: $filter, orderBy: $orderBy) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n subscriberTask {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetCronsDocument,
|
||||||
"\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n": typeof types.DeleteCronsDocument,
|
"\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n": typeof types.DeleteCronsDocument,
|
||||||
|
"\n mutation InsertCron($data: CronInsertInput!) {\n cronCreateOne(data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n": typeof types.InsertCronDocument,
|
||||||
"\n mutation InsertFeed($data: FeedsInsertInput!) {\n feedsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n feedType\n token\n }\n }\n": typeof types.InsertFeedDocument,
|
"\n mutation InsertFeed($data: FeedsInsertInput!) {\n feedsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n feedType\n token\n }\n }\n": typeof types.InsertFeedDocument,
|
||||||
"\n mutation DeleteFeed($filter: FeedsFilterInput!) {\n feedsDelete(filter: $filter)\n }\n": typeof types.DeleteFeedDocument,
|
"\n mutation DeleteFeed($filter: FeedsFilterInput!) {\n feedsDelete(filter: $filter)\n }\n": typeof types.DeleteFeedDocument,
|
||||||
"\n query GetSubscriptions($filter: SubscriptionsFilterInput!, $orderBy: SubscriptionsOrderInput!, $pagination: PaginationInput!) {\n subscriptions(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetSubscriptionsDocument,
|
"\n query GetSubscriptions($filter: SubscriptionsFilterInput!, $orderBy: SubscriptionsOrderInput!, $pagination: PaginationInput!) {\n subscriptions(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetSubscriptionsDocument,
|
||||||
@ -43,6 +44,7 @@ const documents: Documents = {
|
|||||||
"\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n": types.CheckCredential3rdAvailableDocument,
|
"\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n": types.CheckCredential3rdAvailableDocument,
|
||||||
"\nquery GetCrons($filter: CronFilterInput!, $orderBy: CronOrderInput!, $pagination: PaginationInput!) {\n cron(pagination: $pagination, filter: $filter, orderBy: $orderBy) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n subscriberTask {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetCronsDocument,
|
"\nquery GetCrons($filter: CronFilterInput!, $orderBy: CronOrderInput!, $pagination: PaginationInput!) {\n cron(pagination: $pagination, filter: $filter, orderBy: $orderBy) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n subscriberTask {\n nodes {\n id,\n job,\n taskType,\n status,\n attempts,\n maxAttempts,\n runAt,\n lastError,\n lockAt,\n lockBy,\n doneAt,\n priority,\n subscription {\n displayName\n sourceUrl\n }\n }\n }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetCronsDocument,
|
||||||
"\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n": types.DeleteCronsDocument,
|
"\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n": types.DeleteCronsDocument,
|
||||||
|
"\n mutation InsertCron($data: CronInsertInput!) {\n cronCreateOne(data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n": types.InsertCronDocument,
|
||||||
"\n mutation InsertFeed($data: FeedsInsertInput!) {\n feedsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n feedType\n token\n }\n }\n": types.InsertFeedDocument,
|
"\n mutation InsertFeed($data: FeedsInsertInput!) {\n feedsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n feedType\n token\n }\n }\n": types.InsertFeedDocument,
|
||||||
"\n mutation DeleteFeed($filter: FeedsFilterInput!) {\n feedsDelete(filter: $filter)\n }\n": types.DeleteFeedDocument,
|
"\n mutation DeleteFeed($filter: FeedsFilterInput!) {\n feedsDelete(filter: $filter)\n }\n": types.DeleteFeedDocument,
|
||||||
"\n query GetSubscriptions($filter: SubscriptionsFilterInput!, $orderBy: SubscriptionsOrderInput!, $pagination: PaginationInput!) {\n subscriptions(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetSubscriptionsDocument,
|
"\n query GetSubscriptions($filter: SubscriptionsFilterInput!, $orderBy: SubscriptionsOrderInput!, $pagination: PaginationInput!) {\n subscriptions(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\n nodes {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetSubscriptionsDocument,
|
||||||
@ -102,6 +104,10 @@ export function gql(source: "\nquery GetCrons($filter: CronFilterInput!, $orderB
|
|||||||
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||||
*/
|
*/
|
||||||
export function gql(source: "\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n"): (typeof documents)["\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n"];
|
export function gql(source: "\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n"): (typeof documents)["\n mutation DeleteCrons($filter: CronFilterInput!) {\n cronDelete(filter: $filter)\n }\n"];
|
||||||
|
/**
|
||||||
|
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||||
|
*/
|
||||||
|
export function gql(source: "\n mutation InsertCron($data: CronInsertInput!) {\n cronCreateOne(data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n"): (typeof documents)["\n mutation InsertCron($data: CronInsertInput!) {\n cronCreateOne(data: $data) {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n"];
|
||||||
/**
|
/**
|
||||||
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||||
*/
|
*/
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,3 @@
|
|||||||
import { DetailCardSkeleton } from '@/components/detail-card-skeleton';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -9,6 +8,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { ContainerHeader } from '@/components/ui/container-header';
|
import { ContainerHeader } from '@/components/ui/container-header';
|
||||||
|
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
|
||||||
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
||||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog';
|
import { Dialog, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { DetailCardSkeleton } from '@/components/detail-card-skeleton';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -9,6 +8,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { ContainerHeader } from '@/components/ui/container-header';
|
import { ContainerHeader } from '@/components/ui/container-header';
|
||||||
|
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
|
||||||
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
@ -0,0 +1,152 @@
|
|||||||
|
import { Cron } from '@/components/domains/cron';
|
||||||
|
import { CronMode } from '@/components/domains/cron/types';
|
||||||
|
import {
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { INSERT_CRON } from '@/domains/recorder/schema/cron';
|
||||||
|
import type {
|
||||||
|
InsertCronMutation,
|
||||||
|
InsertCronMutationVariables,
|
||||||
|
} from '@/infra/graphql/gql/graphql';
|
||||||
|
import { useMutation } from '@apollo/client';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { memo, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const SUBSCRIPTION_TASK_CRON_PRESETS = [
|
||||||
|
{
|
||||||
|
label: 'Every hour',
|
||||||
|
value: '0 0 * * * *',
|
||||||
|
description: 'Runs at the top of every hour',
|
||||||
|
category: 'common',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Daily at midnight',
|
||||||
|
value: '0 0 0 * * *',
|
||||||
|
description: 'Runs once daily at 00:00',
|
||||||
|
category: 'daily',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Daily at 9 AM',
|
||||||
|
value: '0 0 9 * * *',
|
||||||
|
description: 'Runs daily at 9:00 AM',
|
||||||
|
category: 'daily',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every Sunday',
|
||||||
|
value: '0 0 0 * * 0',
|
||||||
|
description: 'Runs every Sunday at midnight',
|
||||||
|
category: 'weekly',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'First day of month',
|
||||||
|
value: '0 0 0 1 * *',
|
||||||
|
description: 'Runs on the 1st day of every month',
|
||||||
|
category: 'monthly',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Every year',
|
||||||
|
value: '0 0 0 1 1 *',
|
||||||
|
description: 'Runs on January 1st every year',
|
||||||
|
category: 'yearly',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export type SubscriptionCronCreationViewCompletePayload = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SubscriptionCronCreationViewProps {
|
||||||
|
subscriptionId: number;
|
||||||
|
onComplete: (payload: SubscriptionCronCreationViewCompletePayload) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubscriptionCronCreationView = memo(
|
||||||
|
({ subscriptionId, onComplete }: SubscriptionCronCreationViewProps) => {
|
||||||
|
const [insertCron, { loading: loadingInsert }] = useMutation<
|
||||||
|
InsertCronMutation,
|
||||||
|
InsertCronMutationVariables
|
||||||
|
>(INSERT_CRON, {
|
||||||
|
onCompleted: (data) => {
|
||||||
|
toast.success('Cron created');
|
||||||
|
onComplete(data.cronCreateOne);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Failed to sync subscription', {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = loadingInsert;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Cron
|
||||||
|
mode={CronMode.Both}
|
||||||
|
withCard={true}
|
||||||
|
showPresets={false}
|
||||||
|
presets={SUBSCRIPTION_TASK_CRON_PRESETS}
|
||||||
|
timezone={'Asia/Shanghai'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 flex flex-row items-center justify-center gap-2">
|
||||||
|
<Spinner variant="circle-filled" size="16" />
|
||||||
|
<span>Creating cron...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface SubscriptionCronCreationDialogContentProps {
|
||||||
|
subscriptionId: number;
|
||||||
|
onCancel?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubscriptionCronCreationDialogContent = memo(
|
||||||
|
({
|
||||||
|
subscriptionId,
|
||||||
|
onCancel,
|
||||||
|
}: SubscriptionCronCreationDialogContentProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleCreationComplete = useCallback(
|
||||||
|
(payload: SubscriptionCronCreationViewCompletePayload) => {
|
||||||
|
navigate({
|
||||||
|
to: '/tasks/cron/detail/$id',
|
||||||
|
params: {
|
||||||
|
id: `${payload.id}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent
|
||||||
|
onAbort={onCancel}
|
||||||
|
className="flex max-h-[80vh] flex-col overflow-y-auto xl:max-w-2xl"
|
||||||
|
>
|
||||||
|
<DialogHeader className="sticky">
|
||||||
|
<DialogTitle>Create Cron</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a cron to execute the subscription.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<SubscriptionCronCreationView
|
||||||
|
subscriptionId={subscriptionId}
|
||||||
|
onComplete={handleCreationComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
@ -18,17 +18,17 @@ import { RefreshCcwIcon } from 'lucide-react';
|
|||||||
import { memo, useCallback } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export type SubscriptionSyncViewCompletePayload = {
|
export type SubscriptionTaskCreationViewCompletePayload = {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SubscriptionSyncViewProps {
|
export interface SubscriptionTaskCreationViewProps {
|
||||||
id: number;
|
subscriptionId: number;
|
||||||
onComplete: (payload: SubscriptionSyncViewCompletePayload) => void;
|
onComplete: (payload: SubscriptionTaskCreationViewCompletePayload) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubscriptionSyncView = memo(
|
export const SubscriptionTaskCreationView = memo(
|
||||||
({ id, onComplete }: SubscriptionSyncViewProps) => {
|
({ subscriptionId, onComplete }: SubscriptionTaskCreationViewProps) => {
|
||||||
const [insertSubscriberTask, { loading: loadingInsert }] = useMutation<
|
const [insertSubscriberTask, { loading: loadingInsert }] = useMutation<
|
||||||
InsertSubscriberTaskMutation,
|
InsertSubscriberTaskMutation,
|
||||||
InsertSubscriberTaskMutationVariables
|
InsertSubscriberTaskMutationVariables
|
||||||
@ -56,7 +56,7 @@ export const SubscriptionSyncView = memo(
|
|||||||
variables: {
|
variables: {
|
||||||
data: {
|
data: {
|
||||||
job: {
|
job: {
|
||||||
subscriptionId: id,
|
subscriptionId: subscriptionId,
|
||||||
taskType: SubscriberTaskTypeEnum.SyncOneSubscriptionSources,
|
taskType: SubscriberTaskTypeEnum.SyncOneSubscriptionSources,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -75,7 +75,7 @@ export const SubscriptionSyncView = memo(
|
|||||||
variables: {
|
variables: {
|
||||||
data: {
|
data: {
|
||||||
job: {
|
job: {
|
||||||
subscriptionId: id,
|
subscriptionId: subscriptionId,
|
||||||
taskType:
|
taskType:
|
||||||
SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsIncremental,
|
SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsIncremental,
|
||||||
},
|
},
|
||||||
@ -95,7 +95,7 @@ export const SubscriptionSyncView = memo(
|
|||||||
variables: {
|
variables: {
|
||||||
data: {
|
data: {
|
||||||
job: {
|
job: {
|
||||||
subscriptionId: id,
|
subscriptionId: subscriptionId,
|
||||||
taskType:
|
taskType:
|
||||||
SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsFull,
|
SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsFull,
|
||||||
},
|
},
|
||||||
@ -111,7 +111,7 @@ export const SubscriptionSyncView = memo(
|
|||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute inset-0 flex flex-row items-center justify-center gap-2">
|
<div className="absolute inset-0 flex flex-row items-center justify-center gap-2">
|
||||||
<Spinner variant="circle-filled" size="16" />
|
<Spinner variant="circle-filled" size="16" />
|
||||||
<span>Syncing...</span>
|
<span>Running...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -119,18 +119,20 @@ export const SubscriptionSyncView = memo(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface SubscriptionSyncDialogContentProps {
|
export interface SubscriptionTaskCreationDialogContentProps {
|
||||||
id: number;
|
subscriptionId: number;
|
||||||
onCancel?: VoidFunction;
|
onCancel?: VoidFunction;
|
||||||
isCron?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubscriptionSyncDialogContent = memo(
|
export const SubscriptionTaskCreationDialogContent = memo(
|
||||||
({ id, onCancel }: SubscriptionSyncDialogContentProps) => {
|
({
|
||||||
|
subscriptionId,
|
||||||
|
onCancel,
|
||||||
|
}: SubscriptionTaskCreationDialogContentProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleSyncComplete = useCallback(
|
const handleCreationComplete = useCallback(
|
||||||
(payload: SubscriptionSyncViewCompletePayload) => {
|
(payload: SubscriptionTaskCreationViewCompletePayload) => {
|
||||||
navigate({
|
navigate({
|
||||||
to: '/tasks/detail/$id',
|
to: '/tasks/detail/$id',
|
||||||
params: {
|
params: {
|
||||||
@ -144,12 +146,15 @@ export const SubscriptionSyncDialogContent = memo(
|
|||||||
return (
|
return (
|
||||||
<DialogContent onAbort={onCancel}>
|
<DialogContent onAbort={onCancel}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Sync Subscription</DialogTitle>
|
<DialogTitle>Run Task</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Sync the subscription with sources and feeds.
|
Run the task for the subscription.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<SubscriptionSyncView id={id} onComplete={handleSyncComplete} />
|
<SubscriptionTaskCreationView
|
||||||
|
subscriptionId={subscriptionId}
|
||||||
|
onComplete={handleCreationComplete}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -1,4 +1,3 @@
|
|||||||
import { DetailCardSkeleton } from '@/components/detail-card-skeleton';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -9,6 +8,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { ContainerHeader } from '@/components/ui/container-header';
|
import { ContainerHeader } from '@/components/ui/container-header';
|
||||||
|
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
|
||||||
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
||||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog';
|
import { Dialog, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { Img } from '@/components/ui/img';
|
import { Img } from '@/components/ui/img';
|
||||||
@ -47,7 +47,8 @@ import {
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { prettyTaskType } from '../tasks/-pretty-task-type';
|
import { prettyTaskType } from '../tasks/-pretty-task-type';
|
||||||
import { SubscriptionSyncDialogContent } from './-sync';
|
import { SubscriptionCronCreationDialogContent } from './-cron-creation';
|
||||||
|
import { SubscriptionTaskCreationDialogContent } from './-task-creation';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/subscriptions/detail/$id')({
|
export const Route = createFileRoute('/_app/subscriptions/detail/$id')({
|
||||||
component: SubscriptionDetailRouteComponent,
|
component: SubscriptionDetailRouteComponent,
|
||||||
@ -404,19 +405,18 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ListIcon className="h-4 w-4" />
|
<ListIcon className="h-4 w-4" />
|
||||||
Crons
|
More
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<RefreshCcwIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
Setup Cron
|
Add Cron
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<SubscriptionSyncDialogContent
|
<SubscriptionCronCreationDialogContent
|
||||||
id={subscription.id}
|
subscriptionId={subscription.id}
|
||||||
onCancel={handleReload}
|
onCancel={handleReload}
|
||||||
isCron={true}
|
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
@ -477,17 +477,17 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ListIcon className="h-4 w-4" />
|
<ListIcon className="h-4 w-4" />
|
||||||
Tasks
|
More
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<RefreshCcwIcon className="h-4 w-4" />
|
<RefreshCcwIcon className="h-4 w-4" />
|
||||||
Sync
|
Run Task
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<SubscriptionSyncDialogContent
|
<SubscriptionTaskCreationDialogContent
|
||||||
id={subscription.id}
|
subscriptionId={subscription.id}
|
||||||
onCancel={handleReload}
|
onCancel={handleReload}
|
||||||
/>
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { DetailCardSkeleton } from '@/components/detail-card-skeleton';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -9,6 +8,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { ContainerHeader } from '@/components/ui/container-header';
|
import { ContainerHeader } from '@/components/ui/container-header';
|
||||||
|
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
|
||||||
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
||||||
import { FormFieldErrors } from '@/components/ui/form-field-errors';
|
import { FormFieldErrors } from '@/components/ui/form-field-errors';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
@ -47,7 +47,7 @@ import { format } from 'date-fns';
|
|||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { SubscriptionSyncDialogContent } from './-sync';
|
import { SubscriptionTaskCreationDialogContent } from './-task-creation';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/subscriptions/manage')({
|
export const Route = createFileRoute('/_app/subscriptions/manage')({
|
||||||
component: SubscriptionManageRouteComponent,
|
component: SubscriptionManageRouteComponent,
|
||||||
@ -238,7 +238,9 @@ function SubscriptionManageRouteComponent() {
|
|||||||
Sync
|
Sync
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<SubscriptionSyncDialogContent id={row.original.id} />
|
<SubscriptionTaskCreationDialogContent
|
||||||
|
subscriptionId={row.original.id}
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</DropdownMenuActions>
|
</DropdownMenuActions>
|
||||||
),
|
),
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { DetailCardSkeleton } from '@/components/detail-card-skeleton';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -9,6 +8,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { ContainerHeader } from '@/components/ui/container-header';
|
import { ContainerHeader } from '@/components/ui/container-header';
|
||||||
|
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
|
||||||
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { QueryErrorView } from '@/components/ui/query-error-view';
|
import { QueryErrorView } from '@/components/ui/query-error-view';
|
||||||
|
@ -44,6 +44,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
|
{
|
||||||
|
"include": ["**/tsconfig.json", "**/tsconfig.*.json"],
|
||||||
|
"json": {
|
||||||
|
"parser": {
|
||||||
|
"allowComments": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"include": ["apps/webui/src/infra/graphql/gql/**/*"],
|
"include": ["apps/webui/src/infra/graphql/gql/**/*"],
|
||||||
"linter": {
|
"linter": {
|
||||||
|
62
pnpm-lock.yaml
generated
62
pnpm-lock.yaml
generated
@ -89,6 +89,12 @@ importers:
|
|||||||
'@corvu/resizable':
|
'@corvu/resizable':
|
||||||
specifier: ^0.2.5
|
specifier: ^0.2.5
|
||||||
version: 0.2.5(solid-js@1.9.7)
|
version: 0.2.5(solid-js@1.9.7)
|
||||||
|
'@datasert/cronjs-matcher':
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0
|
||||||
|
'@datasert/cronjs-parser':
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0
|
||||||
'@graphiql/toolkit':
|
'@graphiql/toolkit':
|
||||||
specifier: ^0.11.3
|
specifier: ^0.11.3
|
||||||
version: 0.11.3(@types/node@24.0.10)(graphql-ws@6.0.4(graphql@16.11.0)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@6.0.5)))(graphql@16.11.0)
|
version: 0.11.3(@types/node@24.0.10)(graphql-ws@6.0.4(graphql@16.11.0)(ws@8.18.2(bufferutil@4.0.9)(utf-8-validate@6.0.5)))(graphql@16.11.0)
|
||||||
@ -263,6 +269,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
|
tailwind-scrollbar:
|
||||||
|
specifier: ^4.0.2
|
||||||
|
version: 4.0.2(react@19.1.0)(tailwindcss@4.1.10)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.10
|
specifier: ^4.1.10
|
||||||
version: 4.1.10
|
version: 4.1.10
|
||||||
@ -712,6 +721,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@datasert/cronjs-matcher@1.4.0':
|
||||||
|
resolution: {integrity: sha512-5wAAKYfClZQDWjOeGReEnGLlBKds5K0CitnTv17sH32X4PSuck1dysX71zzCgrm0JCSpobDNg4b292ewhoy6ww==}
|
||||||
|
|
||||||
|
'@datasert/cronjs-parser@1.4.0':
|
||||||
|
resolution: {integrity: sha512-zHGlrWanS4Zjgf0aMi/sp/HTSa2xWDEtXW9xshhlGf/jPx3zTIqfX14PZnoFF7XVOwzC49Zy0SFWG90rlRY36Q==}
|
||||||
|
|
||||||
'@date-fns/tz@1.2.0':
|
'@date-fns/tz@1.2.0':
|
||||||
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
|
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
|
||||||
|
|
||||||
@ -3167,6 +3182,9 @@ packages:
|
|||||||
'@types/node@24.0.10':
|
'@types/node@24.0.10':
|
||||||
resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==}
|
resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==}
|
||||||
|
|
||||||
|
'@types/prismjs@1.26.5':
|
||||||
|
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
|
||||||
|
|
||||||
'@types/react-dom@19.1.6':
|
'@types/react-dom@19.1.6':
|
||||||
resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==}
|
resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -5008,6 +5026,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
luxon@3.6.1:
|
||||||
|
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||||
|
|
||||||
@ -5467,6 +5489,11 @@ packages:
|
|||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
prism-react-renderer@2.4.1:
|
||||||
|
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.0.0'
|
||||||
|
|
||||||
prismjs@1.29.0:
|
prismjs@1.29.0:
|
||||||
resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
|
resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -6162,6 +6189,12 @@ packages:
|
|||||||
tailwind-merge@3.3.1:
|
tailwind-merge@3.3.1:
|
||||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||||
|
|
||||||
|
tailwind-scrollbar@4.0.2:
|
||||||
|
resolution: {integrity: sha512-wAQiIxAPqk0MNTPptVe/xoyWi27y+NRGnTwvn4PQnbvB9kp8QUBiGl/wsfoVBHnQxTmhXJSNt9NHTmcz9EivFA==}
|
||||||
|
engines: {node: '>=12.13.0'}
|
||||||
|
peerDependencies:
|
||||||
|
tailwindcss: 4.x
|
||||||
|
|
||||||
tailwindcss@4.1.10:
|
tailwindcss@4.1.10:
|
||||||
resolution: {integrity: sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==}
|
resolution: {integrity: sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==}
|
||||||
|
|
||||||
@ -7216,6 +7249,13 @@ snapshots:
|
|||||||
'@csstools/css-tokenizer@3.0.4':
|
'@csstools/css-tokenizer@3.0.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@datasert/cronjs-matcher@1.4.0':
|
||||||
|
dependencies:
|
||||||
|
'@datasert/cronjs-parser': 1.4.0
|
||||||
|
luxon: 3.6.1
|
||||||
|
|
||||||
|
'@datasert/cronjs-parser@1.4.0': {}
|
||||||
|
|
||||||
'@date-fns/tz@1.2.0': {}
|
'@date-fns/tz@1.2.0': {}
|
||||||
|
|
||||||
'@emnapi/runtime@1.4.3':
|
'@emnapi/runtime@1.4.3':
|
||||||
@ -9812,6 +9852,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.8.0
|
undici-types: 7.8.0
|
||||||
|
|
||||||
|
'@types/prismjs@1.26.5': {}
|
||||||
|
|
||||||
'@types/react-dom@19.1.6(@types/react@19.0.1)':
|
'@types/react-dom@19.1.6(@types/react@19.0.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.0.1
|
'@types/react': 19.0.1
|
||||||
@ -10219,7 +10261,7 @@ snapshots:
|
|||||||
|
|
||||||
browserslist@4.25.0:
|
browserslist@4.25.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
caniuse-lite: 1.0.30001721
|
caniuse-lite: 1.0.30001726
|
||||||
electron-to-chromium: 1.5.165
|
electron-to-chromium: 1.5.165
|
||||||
node-releases: 2.0.19
|
node-releases: 2.0.19
|
||||||
update-browserslist-db: 1.1.3(browserslist@4.25.0)
|
update-browserslist-db: 1.1.3(browserslist@4.25.0)
|
||||||
@ -10282,8 +10324,7 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001721: {}
|
caniuse-lite@1.0.30001721: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001726:
|
caniuse-lite@1.0.30001726: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
capital-case@1.0.4:
|
capital-case@1.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -11892,6 +11933,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|
||||||
|
luxon@3.6.1: {}
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
@ -12317,6 +12360,12 @@ snapshots:
|
|||||||
|
|
||||||
prettier@3.5.3: {}
|
prettier@3.5.3: {}
|
||||||
|
|
||||||
|
prism-react-renderer@2.4.1(react@19.1.0):
|
||||||
|
dependencies:
|
||||||
|
'@types/prismjs': 1.26.5
|
||||||
|
clsx: 2.1.1
|
||||||
|
react: 19.1.0
|
||||||
|
|
||||||
prismjs@1.29.0: {}
|
prismjs@1.29.0: {}
|
||||||
|
|
||||||
prismjs@1.30.0: {}
|
prismjs@1.30.0: {}
|
||||||
@ -13154,6 +13203,13 @@ snapshots:
|
|||||||
|
|
||||||
tailwind-merge@3.3.1: {}
|
tailwind-merge@3.3.1: {}
|
||||||
|
|
||||||
|
tailwind-scrollbar@4.0.2(react@19.1.0)(tailwindcss@4.1.10):
|
||||||
|
dependencies:
|
||||||
|
prism-react-renderer: 2.4.1(react@19.1.0)
|
||||||
|
tailwindcss: 4.1.10
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- react
|
||||||
|
|
||||||
tailwindcss@4.1.10: {}
|
tailwindcss@4.1.10: {}
|
||||||
|
|
||||||
tapable@2.2.2: {}
|
tapable@2.2.2: {}
|
||||||
|
@ -19,8 +19,9 @@
|
|||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
// controlled by biome
|
||||||
"noUnusedParameters": true,
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
"useDefineForClassFields": true
|
"useDefineForClassFields": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,6 @@
|
|||||||
{
|
{
|
||||||
"path": "./apps/webui"
|
"path": "./apps/webui"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"path": "./packages/email"
|
"path": "./packages/email"
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user