Compare commits
8 Commits
147df00155
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 94919878ea | |||
| 81bf27ed28 | |||
| 5be5b9f634 | |||
| 6cdd8c27ce | |||
| 4174cea728 | |||
| 3aad31a36b | |||
| 004fed9b2e | |||
| a1c2eeded1 |
85
Cargo.lock
generated
85
Cargo.lock
generated
@@ -551,7 +551,7 @@ dependencies = [
|
|||||||
"derive_builder",
|
"derive_builder",
|
||||||
"diligent-date-parser",
|
"diligent-date-parser",
|
||||||
"never",
|
"never",
|
||||||
"quick-xml",
|
"quick-xml 0.37.5",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1260,9 +1260,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.40"
|
version = "4.5.41"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
|
checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap_builder",
|
"clap_builder",
|
||||||
"clap_derive",
|
"clap_derive",
|
||||||
@@ -1270,9 +1270,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_builder"
|
name = "clap_builder"
|
||||||
version = "4.5.40"
|
version = "4.5.41"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
|
checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstream",
|
"anstream",
|
||||||
"anstyle",
|
"anstyle",
|
||||||
@@ -1282,9 +1282,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.5.40"
|
version = "4.5.41"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
|
checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -1922,6 +1922,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derivative"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 1.0.109",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_builder"
|
name = "derive_builder"
|
||||||
version = "0.20.2"
|
version = "0.20.2"
|
||||||
@@ -2332,11 +2343,12 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fancy-regex"
|
name = "fancy-regex"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
|
checksum = "d6215aee357f8c7c989ebb4b8466ca4d7dc93b3957039f2fc3ea2ade8ea5f279"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bit-set",
|
"bit-set",
|
||||||
|
"derivative",
|
||||||
"regex-automata 0.4.9",
|
"regex-automata 0.4.9",
|
||||||
"regex-syntax 0.8.5",
|
"regex-syntax 0.8.5",
|
||||||
]
|
]
|
||||||
@@ -4394,7 +4406,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"httparse",
|
"httparse",
|
||||||
"network-interface",
|
"network-interface",
|
||||||
"quick-xml",
|
"quick-xml 0.37.5",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -4846,15 +4858,6 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nanoid"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
|
|
||||||
dependencies = [
|
|
||||||
"rand 0.8.5",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.14"
|
version = "0.2.14"
|
||||||
@@ -5175,7 +5178,7 @@ dependencies = [
|
|||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"parking_lot 0.12.4",
|
"parking_lot 0.12.4",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"quick-xml",
|
"quick-xml 0.37.5",
|
||||||
"rand 0.9.1",
|
"rand 0.9.1",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -5228,7 +5231,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"md-5",
|
"md-5",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"quick-xml",
|
"quick-xml 0.37.5",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -6514,6 +6517,16 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.38.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.8"
|
version = "0.11.8"
|
||||||
@@ -6766,6 +6779,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",
|
||||||
@@ -6797,7 +6811,6 @@ dependencies = [
|
|||||||
"mime_guess",
|
"mime_guess",
|
||||||
"mockito",
|
"mockito",
|
||||||
"moka",
|
"moka",
|
||||||
"nanoid",
|
|
||||||
"nom 8.0.0",
|
"nom 8.0.0",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
@@ -6807,7 +6820,7 @@ dependencies = [
|
|||||||
"paste",
|
"paste",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"polars",
|
"polars",
|
||||||
"quick-xml",
|
"quick-xml 0.38.0",
|
||||||
"quirks_path",
|
"quirks_path",
|
||||||
"rand 0.9.1",
|
"rand 0.9.1",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -6836,6 +6849,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"tracing-test",
|
||||||
"tracing-tree",
|
"tracing-tree",
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
"typed-builder 0.21.0",
|
"typed-builder 0.21.0",
|
||||||
@@ -7241,7 +7255,7 @@ dependencies = [
|
|||||||
"atom_syndication",
|
"atom_syndication",
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
"never",
|
"never",
|
||||||
"quick-xml",
|
"quick-xml 0.37.5",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7668,7 +7682,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "seaography"
|
name = "seaography"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
source = "git+https://github.com/dumtruck/seaography.git?rev=9f7fc7c#9f7fc7cf05234abe35fd9144c895321dd2b5db62"
|
source = "git+https://github.com/dumtruck/seaography.git?rev=292cdd2#292cdd248217fdcf81c41aa97fe1c047c9b5f4de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-graphql",
|
"async-graphql",
|
||||||
"fnv",
|
"fnv",
|
||||||
@@ -9244,6 +9258,27 @@ dependencies = [
|
|||||||
"tracing-serde",
|
"tracing-serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-test"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "557b891436fe0d5e0e363427fc7f217abf9ccd510d5136549847bdcbcd011d68"
|
||||||
|
dependencies = [
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"tracing-test-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-test-macro"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.104",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-tree"
|
name = "tracing-tree"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|||||||
25
Cargo.toml
25
Cargo.toml
@@ -31,22 +31,22 @@ reqwest = { version = "0.12.20", features = [
|
|||||||
"macos-system-configuration",
|
"macos-system-configuration",
|
||||||
"cookies",
|
"cookies",
|
||||||
] }
|
] }
|
||||||
moka = "0.12"
|
moka = "0.12.10"
|
||||||
futures = "0.3"
|
futures = "0.3.31"
|
||||||
quirks_path = "0.1"
|
quirks_path = "0.1.1"
|
||||||
snafu = { version = "0.8", features = ["futures"] }
|
snafu = { version = "0.8.0", features = ["futures"] }
|
||||||
testcontainers = { version = "0.24" }
|
testcontainers = { version = "0.24.0" }
|
||||||
testcontainers-modules = { version = "0.12.1" }
|
testcontainers-modules = { version = "0.12.1" }
|
||||||
testcontainers-ext = { version = "0.1.0", features = ["tracing"] }
|
testcontainers-ext = { version = "0.1.0", features = ["tracing"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
tokio = { version = "1.45.1", features = [
|
tokio = { version = "1.46", features = [
|
||||||
"macros",
|
"macros",
|
||||||
"fs",
|
"fs",
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
"signal",
|
"signal",
|
||||||
] }
|
] }
|
||||||
serde_json = "1"
|
serde_json = "1.0.140"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1.88"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
url = "2.5.2"
|
url = "2.5.2"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
@@ -64,7 +64,7 @@ convert_case = "0.8"
|
|||||||
color-eyre = "0.6.5"
|
color-eyre = "0.6.5"
|
||||||
inquire = "0.7.5"
|
inquire = "0.7.5"
|
||||||
image = "0.25.6"
|
image = "0.25.6"
|
||||||
uuid = { version = "1.6.0", features = ["v4"] }
|
uuid = { version = "1.6.0", features = ["v7"] }
|
||||||
maplit = "1.0.2"
|
maplit = "1.0.2"
|
||||||
once_cell = "1.20.2"
|
once_cell = "1.20.2"
|
||||||
rand = "0.9.1"
|
rand = "0.9.1"
|
||||||
@@ -77,11 +77,12 @@ http = "1.2.0"
|
|||||||
async-stream = "0.3.6"
|
async-stream = "0.3.6"
|
||||||
serde_variant = "0.1.3"
|
serde_variant = "0.1.3"
|
||||||
tracing-appender = "0.2.3"
|
tracing-appender = "0.2.3"
|
||||||
clap = "4.5.40"
|
clap = "4.5.41"
|
||||||
ipnetwork = "0.21.1"
|
ipnetwork = "0.21.1"
|
||||||
typed-builder = "0.21.0"
|
typed-builder = "0.21.0"
|
||||||
nanoid = "0.4.0"
|
nanoid = "0.4.0"
|
||||||
webp = "0.3.0"
|
webp = "0.3.0"
|
||||||
|
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
seaography = { git = "https://github.com/dumtruck/seaography.git", rev = "9f7fc7c" }
|
seaography = { git = "https://github.com/dumtruck/seaography.git", rev = "292cdd2" }
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"filesOrder":["konobangu","konobangu-prod","mikan-doppel"],"selectedList":["mikan-doppel","konobangu-prod"],"disabledDefalutRules":true,"defalutRules":""}
|
{"filesOrder":["konobangu","konobangu-prod","mikan-doppel"],"selectedList":["mikan-doppel","konobangu"],"disabledDefalutRules":true,"defalutRules":""}
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ tracing-appender = { workspace = true }
|
|||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
ipnetwork = { workspace = true }
|
ipnetwork = { workspace = true }
|
||||||
typed-builder = { workspace = true }
|
typed-builder = { workspace = true }
|
||||||
nanoid = { workspace = true }
|
|
||||||
webp = { workspace = true }
|
webp = { workspace = true }
|
||||||
|
|
||||||
sea-orm = { version = "1.1", features = [
|
sea-orm = { version = "1.1", features = [
|
||||||
@@ -110,7 +109,7 @@ sea-orm = { version = "1.1", features = [
|
|||||||
figment = { version = "0.10", features = ["toml", "json", "env", "yaml"] }
|
figment = { version = "0.10", features = ["toml", "json", "env", "yaml"] }
|
||||||
sea-orm-migration = { version = "1.1", features = ["runtime-tokio"] }
|
sea-orm-migration = { version = "1.1", features = ["runtime-tokio"] }
|
||||||
rss = { version = "2", features = ["builders", "with-serde"] }
|
rss = { version = "2", features = ["builders", "with-serde"] }
|
||||||
fancy-regex = "0.14"
|
fancy-regex = "0.15"
|
||||||
lightningcss = "1.0.0-alpha.66"
|
lightningcss = "1.0.0-alpha.66"
|
||||||
html-escape = "0.2.13"
|
html-escape = "0.2.13"
|
||||||
opendal = { version = "0.53", features = ["default", "services-fs"] }
|
opendal = { version = "0.53", features = ["default", "services-fs"] }
|
||||||
@@ -161,7 +160,7 @@ polars = { version = "0.49.1", features = [
|
|||||||
"lazy",
|
"lazy",
|
||||||
"diagonal_concat",
|
"diagonal_concat",
|
||||||
], optional = true }
|
], optional = true }
|
||||||
quick-xml = { version = "0.37.5", features = [
|
quick-xml = { version = "0.38", features = [
|
||||||
"serialize",
|
"serialize",
|
||||||
"serde-types",
|
"serde-types",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -170,11 +169,13 @@ 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 }
|
||||||
color-eyre = { workspace = true }
|
color-eyre = { workspace = true }
|
||||||
serial_test = "3"
|
serial_test = "3"
|
||||||
insta = { version = "1", features = ["redactions", "toml", "filters"] }
|
insta = { version = "1", features = ["redactions", "toml", "filters"] }
|
||||||
rstest = "0.25"
|
|
||||||
ctor = "0.4.0"
|
ctor = "0.4.0"
|
||||||
|
tracing-test = "0.2.5"
|
||||||
|
rstest = "0.25"
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ impl App {
|
|||||||
Ok::<(), RecorderError>(())
|
Ok::<(), RecorderError>(())
|
||||||
},
|
},
|
||||||
async {
|
async {
|
||||||
task.run(if graceful_shutdown {
|
task.run_with_signal(if graceful_shutdown {
|
||||||
Some(Self::shutdown_signal)
|
Some(Self::shutdown_signal)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ use crate::{
|
|||||||
MIKAN_EPISODE_HOMEPAGE_PATH, MIKAN_FANSUB_HOMEPAGE_PATH, MIKAN_FANSUB_ID_QUERY_KEY,
|
MIKAN_EPISODE_HOMEPAGE_PATH, MIKAN_FANSUB_HOMEPAGE_PATH, MIKAN_FANSUB_ID_QUERY_KEY,
|
||||||
MIKAN_POSTER_BUCKET_KEY, MIKAN_SEASON_FLOW_PAGE_PATH, MIKAN_SEASON_STR_QUERY_KEY,
|
MIKAN_POSTER_BUCKET_KEY, MIKAN_SEASON_FLOW_PAGE_PATH, MIKAN_SEASON_STR_QUERY_KEY,
|
||||||
MIKAN_SUBSCRIBER_SUBSCRIPTION_RSS_PATH, MIKAN_SUBSCRIBER_SUBSCRIPTION_TOKEN_QUERY_KEY,
|
MIKAN_SUBSCRIBER_SUBSCRIPTION_RSS_PATH, MIKAN_SUBSCRIBER_SUBSCRIPTION_TOKEN_QUERY_KEY,
|
||||||
MIKAN_YEAR_QUERY_KEY, MikanClient, build_mikan_bangumi_subscription_rss_url,
|
MIKAN_UNKNOWN_FANSUB_ID, MIKAN_YEAR_QUERY_KEY, MikanClient,
|
||||||
build_mikan_subscriber_subscription_rss_url,
|
build_mikan_bangumi_subscription_rss_url, build_mikan_subscriber_subscription_rss_url,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
media::{
|
media::{
|
||||||
@@ -564,16 +564,17 @@ pub fn extract_mikan_episode_meta_from_episode_homepage_html(
|
|||||||
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_episode_id"))
|
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_episode_id"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let fansub_name = html
|
let fansub_name = if mikan_fansub_id == MIKAN_UNKNOWN_FANSUB_ID {
|
||||||
.select(
|
MIKAN_UNKNOWN_FANSUB_ID.to_string()
|
||||||
|
} else {
|
||||||
|
html.select(
|
||||||
&Selector::parse(".bangumi-info a.magnet-link-wrap[href^='/Home/PublishGroup/']")
|
&Selector::parse(".bangumi-info a.magnet-link-wrap[href^='/Home/PublishGroup/']")
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.next()
|
.next()
|
||||||
.map(extract_inner_text_from_element_ref)
|
.map(extract_inner_text_from_element_ref)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("fansub_name")))?
|
||||||
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("fansub_name"))
|
};
|
||||||
})?;
|
|
||||||
|
|
||||||
let origin_poster_src = html.select(bangumi_poster_selector).next().and_then(|el| {
|
let origin_poster_src = html.select(bangumi_poster_selector).next().and_then(|el| {
|
||||||
el.value()
|
el.value()
|
||||||
@@ -685,6 +686,13 @@ pub fn extract_mikan_fansub_meta_from_bangumi_homepage_html(
|
|||||||
html: &Html,
|
html: &Html,
|
||||||
mikan_fansub_id: String,
|
mikan_fansub_id: String,
|
||||||
) -> Option<MikanFansubMeta> {
|
) -> Option<MikanFansubMeta> {
|
||||||
|
if mikan_fansub_id == MIKAN_UNKNOWN_FANSUB_ID {
|
||||||
|
return Some(MikanFansubMeta {
|
||||||
|
mikan_fansub_id,
|
||||||
|
fansub: MIKAN_UNKNOWN_FANSUB_ID.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
html.select(
|
html.select(
|
||||||
&Selector::parse(&format!(
|
&Selector::parse(&format!(
|
||||||
"a.subgroup-name[data-anchor='#{mikan_fansub_id}']"
|
"a.subgroup-name[data-anchor='#{mikan_fansub_id}']"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||||||
use async_graphql::dynamic::ResolverContext;
|
use async_graphql::dynamic::ResolverContext;
|
||||||
use sea_orm::Value as SeaValue;
|
use sea_orm::Value as SeaValue;
|
||||||
use seaography::{Builder as SeaographyBuilder, BuilderContext, SeaResult};
|
use seaography::{Builder as SeaographyBuilder, BuilderContext, SeaResult};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
graphql::{
|
graphql::{
|
||||||
@@ -35,7 +36,9 @@ pub fn register_feeds_to_schema_context(context: &mut BuilderContext) {
|
|||||||
if field_name == entity_create_one_mutation_field_name.as_str()
|
if field_name == entity_create_one_mutation_field_name.as_str()
|
||||||
|| field_name == entity_create_batch_mutation_field_name.as_str()
|
|| field_name == entity_create_batch_mutation_field_name.as_str()
|
||||||
{
|
{
|
||||||
Ok(Some(SeaValue::String(Some(Box::new(nanoid::nanoid!())))))
|
Ok(Some(SeaValue::String(Some(Box::new(
|
||||||
|
Uuid::now_v7().to_string(),
|
||||||
|
)))))
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ pub enum Cron {
|
|||||||
SubscriberId,
|
SubscriberId,
|
||||||
SubscriptionId,
|
SubscriptionId,
|
||||||
CronExpr,
|
CronExpr,
|
||||||
|
CronTimezone,
|
||||||
NextRun,
|
NextRun,
|
||||||
LastRun,
|
LastRun,
|
||||||
LastError,
|
LastError,
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ use crate::{
|
|||||||
Subscriptions, table_auto_z,
|
Subscriptions, table_auto_z,
|
||||||
},
|
},
|
||||||
models::cron::{
|
models::cron::{
|
||||||
CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME, CRON_DUE_EVENT, CronStatus, CronStatusEnum,
|
CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME, CRON_DUE_DEBUG_EVENT, CRON_DUE_EVENT,
|
||||||
NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME, NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME,
|
CronStatus, CronStatusEnum, NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME,
|
||||||
SETUP_CRON_EXTRA_FOREIGN_KEYS_FUNCTION_NAME, SETUP_CRON_EXTRA_FOREIGN_KEYS_TRIGGER_NAME,
|
NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME, SETUP_CRON_EXTRA_FOREIGN_KEYS_FUNCTION_NAME,
|
||||||
|
SETUP_CRON_EXTRA_FOREIGN_KEYS_TRIGGER_NAME,
|
||||||
},
|
},
|
||||||
task::{
|
task::{
|
||||||
SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME, SUBSCRIBER_TASK_APALIS_NAME,
|
SETUP_APALIS_JOBS_EXTRA_FOREIGN_KEYS_FUNCTION_NAME, SUBSCRIBER_TASK_APALIS_NAME,
|
||||||
@@ -30,7 +31,8 @@ impl MigrationTrait for Migration {
|
|||||||
CronStatus::Pending,
|
CronStatus::Pending,
|
||||||
CronStatus::Running,
|
CronStatus::Running,
|
||||||
CronStatus::Completed,
|
CronStatus::Completed,
|
||||||
CronStatus::Failed
|
CronStatus::Failed,
|
||||||
|
CronStatus::Disabled
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -39,6 +41,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))
|
||||||
@@ -47,15 +50,14 @@ impl MigrationTrait for Migration {
|
|||||||
.col(boolean(Cron::Enabled).default(true))
|
.col(boolean(Cron::Enabled).default(true))
|
||||||
.col(string_null(Cron::LockedBy))
|
.col(string_null(Cron::LockedBy))
|
||||||
.col(timestamp_with_time_zone_null(Cron::LockedAt))
|
.col(timestamp_with_time_zone_null(Cron::LockedAt))
|
||||||
.col(integer_null(Cron::TimeoutMs))
|
.col(integer_null(Cron::TimeoutMs).default(5000))
|
||||||
.col(integer(Cron::Attempts))
|
.col(integer(Cron::Attempts).default(0))
|
||||||
.col(integer(Cron::MaxAttempts))
|
.col(integer(Cron::MaxAttempts).default(1))
|
||||||
.col(integer(Cron::Priority))
|
.col(integer(Cron::Priority).default(0))
|
||||||
.col(enumeration(
|
.col(
|
||||||
Cron::Status,
|
enumeration(Cron::Status, CronStatusEnum, CronStatus::iden_values())
|
||||||
CronStatusEnum,
|
.default(CronStatus::Pending),
|
||||||
CronStatus::iden_values(),
|
)
|
||||||
))
|
|
||||||
.col(json_binary_null(Cron::SubscriberTaskCron))
|
.col(json_binary_null(Cron::SubscriberTaskCron))
|
||||||
.col(json_binary_null(Cron::SystemTaskCron))
|
.col(json_binary_null(Cron::SystemTaskCron))
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
@@ -139,7 +141,7 @@ impl MigrationTrait for Migration {
|
|||||||
IF NEW.{next_run} IS NOT NULL
|
IF NEW.{next_run} IS NOT NULL
|
||||||
AND NEW.{next_run} <= CURRENT_TIMESTAMP
|
AND NEW.{next_run} <= CURRENT_TIMESTAMP
|
||||||
AND NEW.{enabled} = true
|
AND NEW.{enabled} = true
|
||||||
AND NEW.{status} = '{pending}'
|
AND NEW.{status} = '{pending}'::{status_type}
|
||||||
AND NEW.{attempts} < NEW.{max_attempts}
|
AND NEW.{attempts} < NEW.{max_attempts}
|
||||||
-- Check if not locked or lock timeout
|
-- Check if not locked or lock timeout
|
||||||
AND (
|
AND (
|
||||||
@@ -171,6 +173,7 @@ impl MigrationTrait for Migration {
|
|||||||
pending = &CronStatus::Pending.to_value(),
|
pending = &CronStatus::Pending.to_value(),
|
||||||
attempts = &Cron::Attempts.to_string(),
|
attempts = &Cron::Attempts.to_string(),
|
||||||
max_attempts = &Cron::MaxAttempts.to_string(),
|
max_attempts = &Cron::MaxAttempts.to_string(),
|
||||||
|
status_type = &CronStatus::name().to_string(),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -194,7 +197,7 @@ impl MigrationTrait for Migration {
|
|||||||
WHERE {next_run} IS NOT NULL
|
WHERE {next_run} IS NOT NULL
|
||||||
AND {next_run} <= CURRENT_TIMESTAMP
|
AND {next_run} <= CURRENT_TIMESTAMP
|
||||||
AND {enabled} = true
|
AND {enabled} = true
|
||||||
AND {status} = '{pending}'
|
AND {status} = '{pending}'::{status_type}
|
||||||
AND {attempts} < {max_attempts}
|
AND {attempts} < {max_attempts}
|
||||||
AND (
|
AND (
|
||||||
{locked_at} IS NULL
|
{locked_at} IS NULL
|
||||||
@@ -206,9 +209,12 @@ impl MigrationTrait for Migration {
|
|||||||
ORDER BY {priority} ASC, {next_run} ASC
|
ORDER BY {priority} ASC, {next_run} ASC
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
LOOP
|
LOOP
|
||||||
|
-- PERFORM pg_notify('{CRON_DUE_DEBUG_EVENT}',format('Found due cron: value=%s; Now time: %s', row_to_json(cron_record)::text, CURRENT_TIMESTAMP));
|
||||||
PERFORM pg_notify('{CRON_DUE_EVENT}', row_to_json(cron_record)::text);
|
PERFORM pg_notify('{CRON_DUE_EVENT}', row_to_json(cron_record)::text);
|
||||||
notification_count := notification_count + 1;
|
notification_count := notification_count + 1;
|
||||||
END LOOP;
|
END LOOP;
|
||||||
|
|
||||||
|
-- PERFORM pg_notify('{CRON_DUE_DEBUG_EVENT}', format('Notification count: %I; Now time: %s', notification_count, CURRENT_TIMESTAMP));
|
||||||
RETURN notification_count;
|
RETURN notification_count;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;"#,
|
$$ LANGUAGE plpgsql;"#,
|
||||||
@@ -222,6 +228,7 @@ impl MigrationTrait for Migration {
|
|||||||
priority = &Cron::Priority.to_string(),
|
priority = &Cron::Priority.to_string(),
|
||||||
attempts = &Cron::Attempts.to_string(),
|
attempts = &Cron::Attempts.to_string(),
|
||||||
max_attempts = &Cron::MaxAttempts.to_string(),
|
max_attempts = &Cron::MaxAttempts.to_string(),
|
||||||
|
status_type = &CronStatus::name().to_string(),
|
||||||
))
|
))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -237,8 +244,8 @@ impl MigrationTrait for Migration {
|
|||||||
.from_col(ApalisJobs::CronId)
|
.from_col(ApalisJobs::CronId)
|
||||||
.to_tbl(Cron::Table)
|
.to_tbl(Cron::Table)
|
||||||
.to_col(Cron::Id)
|
.to_col(Cron::Id)
|
||||||
.on_delete(ForeignKeyAction::NoAction)
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
.on_update(ForeignKeyAction::NoAction),
|
.on_update(ForeignKeyAction::Restrict),
|
||||||
)
|
)
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub const CRON_DUE_EVENT: &str = "cron_due";
|
pub const CRON_DUE_EVENT: &str = "cron_due";
|
||||||
|
pub const CRON_DUE_DEBUG_EVENT: &str = "cron_due_debug";
|
||||||
|
|
||||||
pub const CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME: &str = "check_and_trigger_due_crons";
|
pub const CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME: &str = "check_and_trigger_due_crons";
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
mod core;
|
mod core;
|
||||||
|
|
||||||
pub use core::{
|
pub use core::{
|
||||||
CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME, CRON_DUE_EVENT,
|
CHECK_AND_TRIGGER_DUE_CRONS_FUNCTION_NAME, CRON_DUE_DEBUG_EVENT, CRON_DUE_EVENT,
|
||||||
NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME, NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME,
|
NOTIFY_DUE_CRON_WHEN_MUTATING_FUNCTION_NAME, NOTIFY_DUE_CRON_WHEN_MUTATING_TRIGGER_NAME,
|
||||||
SETUP_CRON_EXTRA_FOREIGN_KEYS_FUNCTION_NAME, SETUP_CRON_EXTRA_FOREIGN_KEYS_TRIGGER_NAME,
|
SETUP_CRON_EXTRA_FOREIGN_KEYS_FUNCTION_NAME, SETUP_CRON_EXTRA_FOREIGN_KEYS_TRIGGER_NAME,
|
||||||
};
|
};
|
||||||
|
|
||||||
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},
|
||||||
@@ -40,6 +41,8 @@ pub enum CronStatus {
|
|||||||
Completed,
|
Completed,
|
||||||
#[sea_orm(string_value = "failed")]
|
#[sea_orm(string_value = "failed")]
|
||||||
Failed,
|
Failed,
|
||||||
|
#[sea_orm(string_value = "disabled")]
|
||||||
|
Disabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, DeriveEntityModel, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, DeriveEntityModel, PartialEq, Serialize, Deserialize)]
|
||||||
@@ -54,13 +57,14 @@ 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>,
|
||||||
pub locked_by: Option<String>,
|
pub locked_by: Option<String>,
|
||||||
pub locked_at: Option<DateTimeUtc>,
|
pub locked_at: Option<DateTimeUtc>,
|
||||||
#[sea_orm(default_expr = "5000")]
|
// default_expr = "5000"
|
||||||
pub timeout_ms: i32,
|
pub timeout_ms: Option<i32>,
|
||||||
#[sea_orm(default_expr = "0")]
|
#[sea_orm(default_expr = "0")]
|
||||||
pub attempts: i32,
|
pub attempts: i32,
|
||||||
#[sea_orm(default_expr = "1")]
|
#[sea_orm(default_expr = "1")]
|
||||||
@@ -136,20 +140,41 @@ pub enum RelatedEntity {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ActiveModelBehavior for ActiveModel {
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
async fn before_save<C>(mut self, _db: &C, _insert: bool) -> Result<Self, DbErr>
|
async fn before_save<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
|
||||||
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
|
||||||
@@ -166,6 +191,15 @@ impl ActiveModelBehavior for ActiveModel {
|
|||||||
"Cron subscriber_id does not match system_task_cron.subscriber_id".to_string(),
|
"Cron subscriber_id does not match system_task_cron.subscriber_id".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let ActiveValue::Set(enabled) = self.enabled
|
||||||
|
&& !insert
|
||||||
|
{
|
||||||
|
if enabled {
|
||||||
|
self.status = Set(CronStatus::Pending)
|
||||||
|
} else {
|
||||||
|
self.status = Set(CronStatus::Disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
@@ -223,7 +257,10 @@ impl Model {
|
|||||||
&& cron.attempts < cron.max_attempts
|
&& cron.attempts < cron.max_attempts
|
||||||
&& cron.status == CronStatus::Pending
|
&& cron.status == CronStatus::Pending
|
||||||
&& (cron.locked_at.is_none_or(|locked_at| {
|
&& (cron.locked_at.is_none_or(|locked_at| {
|
||||||
locked_at + chrono::Duration::milliseconds(cron.timeout_ms as i64) <= Utc::now()
|
cron.timeout_ms.is_some_and(|cron_timeout_ms| {
|
||||||
|
locked_at + chrono::Duration::milliseconds(cron_timeout_ms as i64)
|
||||||
|
<= Utc::now()
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
&& cron.next_run.is_some_and(|next_run| next_run <= Utc::now())
|
&& cron.next_run.is_some_and(|next_run| next_run <= Utc::now())
|
||||||
{
|
{
|
||||||
@@ -269,7 +306,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),
|
||||||
@@ -307,7 +344,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 {
|
||||||
@@ -376,7 +416,15 @@ impl Model {
|
|||||||
locked_cron
|
locked_cron
|
||||||
.mark_cron_failed(
|
.mark_cron_failed(
|
||||||
ctx,
|
ctx,
|
||||||
format!("Cron timeout of {}ms", locked_cron.timeout_ms).as_str(),
|
format!(
|
||||||
|
"Cron timeout of {}ms",
|
||||||
|
locked_cron
|
||||||
|
.timeout_ms
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "Infinite".to_string())
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
retry_duration,
|
retry_duration,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -388,11 +436,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 cron_expr = Cron::new(cron_expr).parse()?;
|
let user_tz = timezone.parse::<Tz>()?;
|
||||||
|
|
||||||
let next = cron_expr.find_next_occurrence(&Utc::now(), false)?;
|
let user_tz_now = Utc::now().with_timezone(&user_tz);
|
||||||
|
|
||||||
Ok(next)
|
let cron_expr = Cron::new(cron_expr).with_seconds_optional().parse()?;
|
||||||
|
|
||||||
|
let next = cron_expr.find_next_occurrence(&user_tz_now, false)?;
|
||||||
|
|
||||||
|
let next_utc = next.with_timezone(&Utc);
|
||||||
|
|
||||||
|
Ok(next_utc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ impl ActiveModelBehavior for ActiveModel {
|
|||||||
C: ConnectionTrait,
|
C: ConnectionTrait,
|
||||||
{
|
{
|
||||||
if insert && let ActiveValue::NotSet = self.token {
|
if insert && let ActiveValue::NotSet = self.token {
|
||||||
let token = nanoid::nanoid!(10);
|
let token = Uuid::now_v7().to_string();
|
||||||
self.token = ActiveValue::Set(token);
|
self.token = ActiveValue::Set(token);
|
||||||
}
|
}
|
||||||
Ok(self)
|
Ok(self)
|
||||||
|
|||||||
@@ -52,23 +52,23 @@ pub enum Relation {
|
|||||||
from = "Column::SubscriberId",
|
from = "Column::SubscriberId",
|
||||||
to = "super::subscribers::Column::Id",
|
to = "super::subscribers::Column::Id",
|
||||||
on_update = "Cascade",
|
on_update = "Cascade",
|
||||||
on_delete = "NoAction"
|
on_delete = "Restrict"
|
||||||
)]
|
)]
|
||||||
Subscriber,
|
Subscriber,
|
||||||
#[sea_orm(
|
#[sea_orm(
|
||||||
belongs_to = "super::subscriptions::Entity",
|
belongs_to = "super::subscriptions::Entity",
|
||||||
from = "Column::SubscriptionId",
|
from = "Column::SubscriptionId",
|
||||||
to = "super::subscriptions::Column::Id",
|
to = "super::subscriptions::Column::Id",
|
||||||
on_update = "NoAction",
|
on_update = "Cascade",
|
||||||
on_delete = "NoAction"
|
on_delete = "Restrict"
|
||||||
)]
|
)]
|
||||||
Subscription,
|
Subscription,
|
||||||
#[sea_orm(
|
#[sea_orm(
|
||||||
belongs_to = "super::cron::Entity",
|
belongs_to = "super::cron::Entity",
|
||||||
from = "Column::CronId",
|
from = "Column::CronId",
|
||||||
to = "super::cron::Column::Id",
|
to = "super::cron::Column::Id",
|
||||||
on_update = "NoAction",
|
on_update = "Cascade",
|
||||||
on_delete = "NoAction"
|
on_delete = "Restrict"
|
||||||
)]
|
)]
|
||||||
Cron,
|
Cron,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ pub enum Relation {
|
|||||||
Feed,
|
Feed,
|
||||||
#[sea_orm(has_many = "super::subscriber_tasks::Entity")]
|
#[sea_orm(has_many = "super::subscriber_tasks::Entity")]
|
||||||
SubscriberTask,
|
SubscriberTask,
|
||||||
|
#[sea_orm(has_many = "super::cron::Entity")]
|
||||||
|
Cron,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<super::subscribers::Entity> for Entity {
|
impl Related<super::subscribers::Entity> for Entity {
|
||||||
@@ -126,6 +128,12 @@ impl Related<super::subscriber_tasks::Entity> for Entity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Related<super::cron::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Cron.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)]
|
||||||
pub enum RelatedEntity {
|
pub enum RelatedEntity {
|
||||||
#[sea_orm(entity = "super::subscribers::Entity")]
|
#[sea_orm(entity = "super::subscribers::Entity")]
|
||||||
@@ -144,6 +152,8 @@ pub enum RelatedEntity {
|
|||||||
Feed,
|
Feed,
|
||||||
#[sea_orm(entity = "super::subscriber_tasks::Entity")]
|
#[sea_orm(entity = "super::subscriber_tasks::Entity")]
|
||||||
SubscriberTask,
|
SubscriberTask,
|
||||||
|
#[sea_orm(entity = "super::cron::Entity")]
|
||||||
|
Cron,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ pub enum Relation {
|
|||||||
belongs_to = "super::cron::Entity",
|
belongs_to = "super::cron::Entity",
|
||||||
from = "Column::CronId",
|
from = "Column::CronId",
|
||||||
to = "super::cron::Column::Id",
|
to = "super::cron::Column::Id",
|
||||||
on_update = "NoAction",
|
on_update = "Cascade",
|
||||||
on_delete = "NoAction"
|
on_delete = "Restrict"
|
||||||
)]
|
)]
|
||||||
Cron,
|
Cron,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ impl StorageService {
|
|||||||
|
|
||||||
if let Some(mut ranges) = ranges {
|
if let Some(mut ranges) = ranges {
|
||||||
if ranges.len() > 1 {
|
if ranges.len() > 1 {
|
||||||
let boundary = Uuid::new_v4().to_string();
|
let boundary = Uuid::now_v7().to_string();
|
||||||
let reader = self.reader(storage_path.as_ref()).await?;
|
let reader = self.reader(storage_path.as_ref()).await?;
|
||||||
let stream: impl Stream<Item = Result<Bytes, RecorderError>> = {
|
let stream: impl Stream<Item = Result<Bytes, RecorderError>> = {
|
||||||
let boundary = boundary.clone();
|
let boundary = boundary.clone();
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ pub struct TaskConfig {
|
|||||||
pub system_task_reenqueue_orphaned_after: Duration,
|
pub system_task_reenqueue_orphaned_after: Duration,
|
||||||
#[serde(default = "default_cron_retry_duration")]
|
#[serde(default = "default_cron_retry_duration")]
|
||||||
pub cron_retry_duration: Duration,
|
pub cron_retry_duration: Duration,
|
||||||
|
#[serde(default = "default_cron_interval_duration")]
|
||||||
|
pub cron_interval_duration: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TaskConfig {
|
impl Default for TaskConfig {
|
||||||
@@ -25,6 +27,7 @@ impl Default for TaskConfig {
|
|||||||
default_subscriber_task_reenqueue_orphaned_after(),
|
default_subscriber_task_reenqueue_orphaned_after(),
|
||||||
system_task_reenqueue_orphaned_after: default_system_task_reenqueue_orphaned_after(),
|
system_task_reenqueue_orphaned_after: default_system_task_reenqueue_orphaned_after(),
|
||||||
cron_retry_duration: default_cron_retry_duration(),
|
cron_retry_duration: default_cron_retry_duration(),
|
||||||
|
cron_interval_duration: default_cron_interval_duration(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,6 +48,10 @@ pub fn default_system_task_workers() -> u32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn default_cron_interval_duration() -> Duration {
|
||||||
|
Duration::from_secs(30)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn default_subscriber_task_reenqueue_orphaned_after() -> Duration {
|
pub fn default_subscriber_task_reenqueue_orphaned_after() -> Duration {
|
||||||
Duration::from_secs(3600)
|
Duration::from_secs(3600)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub use core::{
|
|||||||
|
|
||||||
pub use config::TaskConfig;
|
pub use config::TaskConfig;
|
||||||
pub use registry::{
|
pub use registry::{
|
||||||
OptimizeImageTask, SubscriberTask, SubscriberTaskInput, SubscriberTaskType,
|
EchoTask, OptimizeImageTask, SubscriberTask, SubscriberTaskInput, SubscriberTaskType,
|
||||||
SubscriberTaskTypeEnum, SubscriberTaskTypeVariant, SubscriberTaskTypeVariantIter,
|
SubscriberTaskTypeEnum, SubscriberTaskTypeVariant, SubscriberTaskTypeVariantIter,
|
||||||
SyncOneSubscriptionFeedsFullTask, SyncOneSubscriptionFeedsIncrementalTask,
|
SyncOneSubscriptionFeedsFullTask, SyncOneSubscriptionFeedsIncrementalTask,
|
||||||
SyncOneSubscriptionSourcesTask, SystemTask, SystemTaskInput, SystemTaskType,
|
SyncOneSubscriptionSourcesTask, SystemTask, SystemTaskInput, SystemTaskType,
|
||||||
|
|||||||
@@ -9,6 +9,6 @@ pub use subscriber::{
|
|||||||
};
|
};
|
||||||
pub(crate) use system::register_system_task_type;
|
pub(crate) use system::register_system_task_type;
|
||||||
pub use system::{
|
pub use system::{
|
||||||
OptimizeImageTask, SystemTask, SystemTaskInput, SystemTaskType, SystemTaskTypeEnum,
|
EchoTask, OptimizeImageTask, SystemTask, SystemTaskInput, SystemTaskType, SystemTaskTypeEnum,
|
||||||
SystemTaskTypeVariant, SystemTaskTypeVariantIter,
|
SystemTaskTypeVariant, SystemTaskTypeVariantIter,
|
||||||
};
|
};
|
||||||
|
|||||||
29
apps/recorder/src/task/registry/system/misc.rs
Normal file
29
apps/recorder/src/task/registry/system/misc.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
app::AppContextTrait,
|
||||||
|
errors::RecorderResult,
|
||||||
|
task::{AsyncTaskTrait, register_system_task_type},
|
||||||
|
};
|
||||||
|
|
||||||
|
register_system_task_type! {
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct EchoTask {
|
||||||
|
pub task_id: String,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl AsyncTaskTrait for EchoTask {
|
||||||
|
async fn run_async(self, _ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()> {
|
||||||
|
tracing::info!(
|
||||||
|
"EchoTask {} start running at {}",
|
||||||
|
self.task_id,
|
||||||
|
Utc::now().to_rfc3339()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
mod base;
|
mod base;
|
||||||
mod media;
|
mod media;
|
||||||
|
mod misc;
|
||||||
|
|
||||||
pub(crate) use base::register_system_task_type;
|
pub(crate) use base::register_system_task_type;
|
||||||
pub use media::OptimizeImageTask;
|
pub use media::OptimizeImageTask;
|
||||||
|
pub use misc::EchoTask;
|
||||||
use sea_orm::{DeriveActiveEnum, DeriveDisplay, EnumIter, FromJsonQueryResult};
|
use sea_orm::{DeriveActiveEnum, DeriveDisplay, EnumIter, FromJsonQueryResult};
|
||||||
|
|
||||||
macro_rules! register_system_task_types {
|
macro_rules! register_system_task_types {
|
||||||
@@ -131,30 +133,6 @@ macro_rules! register_system_task_types {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(test, feature = "test-utils")))]
|
|
||||||
register_system_task_types! {
|
|
||||||
task_type_enum: {
|
|
||||||
#[derive(
|
|
||||||
Clone,
|
|
||||||
Debug,
|
|
||||||
Copy,
|
|
||||||
DeriveActiveEnum,
|
|
||||||
DeriveDisplay,
|
|
||||||
EnumIter,
|
|
||||||
)]
|
|
||||||
pub enum SystemTaskType {
|
|
||||||
OptimizeImage => "optimize_image"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
task_enum: {
|
|
||||||
#[derive(Clone, Debug, FromJsonQueryResult)]
|
|
||||||
pub enum SystemTask {
|
|
||||||
OptimizeImage(OptimizeImageTask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-utils"))]
|
|
||||||
register_system_task_types! {
|
register_system_task_types! {
|
||||||
task_type_enum: {
|
task_type_enum: {
|
||||||
#[derive(
|
#[derive(
|
||||||
@@ -174,7 +152,7 @@ register_system_task_types! {
|
|||||||
#[derive(Clone, Debug, FromJsonQueryResult)]
|
#[derive(Clone, Debug, FromJsonQueryResult)]
|
||||||
pub enum SystemTask {
|
pub enum SystemTask {
|
||||||
OptimizeImage(OptimizeImageTask),
|
OptimizeImage(OptimizeImageTask),
|
||||||
Test(crate::test_utils::task::TestSystemTask),
|
Echo(EchoTask),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ use apalis_sql::{
|
|||||||
context::SqlContext,
|
context::SqlContext,
|
||||||
postgres::{PgListen as ApalisPgListen, PostgresStorage as ApalisPostgresStorage},
|
postgres::{PgListen as ApalisPgListen, PostgresStorage as ApalisPostgresStorage},
|
||||||
};
|
};
|
||||||
use sea_orm::sqlx::postgres::PgListener;
|
use sea_orm::{ActiveModelTrait, sqlx::postgres::PgListener};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppContextTrait,
|
app::AppContextTrait,
|
||||||
errors::{RecorderError, RecorderResult},
|
errors::{RecorderError, RecorderResult},
|
||||||
models::cron::{self, CRON_DUE_EVENT},
|
models::cron::{self, CRON_DUE_DEBUG_EVENT, CRON_DUE_EVENT},
|
||||||
task::{
|
task::{
|
||||||
AsyncTaskTrait, SUBSCRIBER_TASK_APALIS_NAME, SYSTEM_TASK_APALIS_NAME, SubscriberTask,
|
AsyncTaskTrait, SUBSCRIBER_TASK_APALIS_NAME, SYSTEM_TASK_APALIS_NAME, SubscriberTask,
|
||||||
TaskConfig,
|
TaskConfig,
|
||||||
@@ -53,7 +54,7 @@ impl TaskService {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
config,
|
config,
|
||||||
cron_worker_id: nanoid::nanoid!(),
|
cron_worker_id: Uuid::now_v7().to_string(),
|
||||||
ctx,
|
ctx,
|
||||||
subscriber_task_storage: Arc::new(RwLock::new(subscriber_task_storage)),
|
subscriber_task_storage: Arc::new(RwLock::new(subscriber_task_storage)),
|
||||||
system_task_storage: Arc::new(RwLock::new(system_task_storage)),
|
system_task_storage: Arc::new(RwLock::new(system_task_storage)),
|
||||||
@@ -136,83 +137,110 @@ impl TaskService {
|
|||||||
Ok(task_id)
|
Ok(task_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run<F, Fut>(&self, shutdown_signal: Option<F>) -> RecorderResult<()>
|
pub async fn add_subscriber_task_cron(
|
||||||
|
&self,
|
||||||
|
cm: cron::ActiveModel,
|
||||||
|
) -> RecorderResult<cron::Model> {
|
||||||
|
let db = self.ctx.db();
|
||||||
|
let m = cm.insert(db).await?;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_system_task_cron(&self, cm: cron::ActiveModel) -> RecorderResult<cron::Model> {
|
||||||
|
let db = self.ctx.db();
|
||||||
|
let m = cm.insert(db).await?;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(&self) -> RecorderResult<()> {
|
||||||
|
self.run_with_signal(None::<fn() -> std::future::Ready<()>>)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_with_signal<F, Fut>(&self, shutdown_signal: Option<F>) -> RecorderResult<()>
|
||||||
where
|
where
|
||||||
F: Fn() -> Fut + Send + 'static,
|
F: FnOnce() -> Fut + Send + 'static,
|
||||||
Fut: Future<Output = ()> + Send,
|
Fut: Future<Output = ()> + Send,
|
||||||
{
|
{
|
||||||
tokio::try_join!(
|
tokio::select! {
|
||||||
async {
|
_ = {
|
||||||
let monitor = self.setup_apalis_monitor().await?;
|
let monitor = self.setup_apalis_monitor().await?;
|
||||||
if let Some(shutdown_signal) = shutdown_signal {
|
async move {
|
||||||
monitor
|
if let Some(shutdown_signal) = shutdown_signal {
|
||||||
.run_with_signal(async move {
|
monitor
|
||||||
shutdown_signal().await;
|
.run_with_signal(async move {
|
||||||
tracing::info!("apalis shutting down...");
|
shutdown_signal().await;
|
||||||
Ok(())
|
tracing::info!("apalis shutting down...");
|
||||||
})
|
Ok(())
|
||||||
.await?;
|
})
|
||||||
} else {
|
.await?;
|
||||||
monitor.run().await?;
|
} else {
|
||||||
|
monitor.run().await?;
|
||||||
|
}
|
||||||
|
Ok::<_, RecorderError>(())
|
||||||
}
|
}
|
||||||
Ok::<_, RecorderError>(())
|
} => {}
|
||||||
},
|
_ = {
|
||||||
async {
|
|
||||||
let listener = self.setup_apalis_listener().await?;
|
let listener = self.setup_apalis_listener().await?;
|
||||||
tokio::task::spawn(async move {
|
async move {
|
||||||
if let Err(e) = listener.listen().await {
|
if let Err(e) = listener.listen().await {
|
||||||
tracing::error!("Error listening to apalis: {e}");
|
tracing::error!("Error listening to apalis: {e}");
|
||||||
}
|
}
|
||||||
});
|
Ok::<_, RecorderError>(())
|
||||||
Ok::<_, RecorderError>(())
|
}
|
||||||
},
|
} => {},
|
||||||
async {
|
_ = {
|
||||||
let listener = self.setup_cron_due_listening().await?;
|
let mut listener = self.setup_cron_due_listening().await?;
|
||||||
let ctx = self.ctx.clone();
|
|
||||||
let cron_worker_id = self.cron_worker_id.clone();
|
let cron_worker_id = self.cron_worker_id.clone();
|
||||||
let retry_duration = chrono::Duration::milliseconds(
|
let retry_duration =
|
||||||
self.config.cron_retry_duration.as_millis() as i64,
|
chrono::Duration::milliseconds(self.config.cron_retry_duration.as_millis() as i64);
|
||||||
);
|
let cron_interval_duration = self.config.cron_interval_duration;
|
||||||
|
async move {
|
||||||
|
listener.listen_all([CRON_DUE_EVENT as &str, CRON_DUE_DEBUG_EVENT as &str]).await?;
|
||||||
|
|
||||||
tokio::task::spawn(async move {
|
tokio::join!(
|
||||||
if let Err(e) =
|
|
||||||
Self::listen_cron_due(listener, ctx, &cron_worker_id, retry_duration).await
|
|
||||||
{
|
|
||||||
tracing::error!("Error listening to cron due: {e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok::<_, RecorderError>(())
|
|
||||||
},
|
|
||||||
async {
|
|
||||||
let ctx = self.ctx.clone();
|
|
||||||
let retry_duration = chrono::Duration::milliseconds(
|
|
||||||
self.config.cron_retry_duration.as_millis() as i64,
|
|
||||||
);
|
|
||||||
tokio::task::spawn(async move {
|
|
||||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60));
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
if let Err(e) = cron::Model::check_and_cleanup_expired_cron_locks(
|
|
||||||
ctx.as_ref(),
|
|
||||||
retry_duration,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
tracing::error!(
|
let ctx = self.ctx.clone();
|
||||||
"Error checking and cleaning up expired cron locks: {e}"
|
async move {
|
||||||
);
|
if let Err(e) =
|
||||||
}
|
Self::listen_cron_due(listener, ctx, &cron_worker_id, retry_duration)
|
||||||
if let Err(e) = cron::Model::check_and_trigger_due_crons(ctx.as_ref()).await
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("Error listening to cron due: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
tracing::error!("Error checking and triggering due crons: {e}");
|
let ctx = self.ctx.clone();
|
||||||
}
|
let mut interval = tokio::time::interval(cron_interval_duration);
|
||||||
}
|
async move {
|
||||||
});
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Err(e) = cron::Model::check_and_cleanup_expired_cron_locks(
|
||||||
|
ctx.as_ref(),
|
||||||
|
retry_duration,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Error checking and cleaning up expired cron locks: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) =
|
||||||
|
cron::Model::check_and_trigger_due_crons(ctx.as_ref()).await
|
||||||
|
{
|
||||||
|
tracing::error!("Error checking and triggering due crons: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Ok::<_, RecorderError>(())
|
||||||
|
}
|
||||||
|
} => {}
|
||||||
|
};
|
||||||
|
|
||||||
Ok::<_, RecorderError>(())
|
|
||||||
}
|
|
||||||
)?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +295,7 @@ impl TaskService {
|
|||||||
async fn setup_cron_due_listening(&self) -> RecorderResult<PgListener> {
|
async fn setup_cron_due_listening(&self) -> RecorderResult<PgListener> {
|
||||||
let pool = self.ctx.db().get_postgres_connection_pool().clone();
|
let pool = self.ctx.db().get_postgres_connection_pool().clone();
|
||||||
let listener = PgListener::connect_with(&pool).await?;
|
let listener = PgListener::connect_with(&pool).await?;
|
||||||
|
tracing::debug!("Cron due listener connected to postgres");
|
||||||
|
|
||||||
Ok(listener)
|
Ok(listener)
|
||||||
}
|
}
|
||||||
@@ -277,17 +306,19 @@ impl TaskService {
|
|||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
retry_duration: chrono::Duration,
|
retry_duration: chrono::Duration,
|
||||||
) -> RecorderResult<()> {
|
) -> RecorderResult<()> {
|
||||||
listener.listen(CRON_DUE_EVENT).await?;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let notification = listener.recv().await?;
|
let notification = listener.recv().await?;
|
||||||
if let Err(e) = cron::Model::handle_cron_notification(
|
if notification.channel() == CRON_DUE_DEBUG_EVENT {
|
||||||
ctx.as_ref(),
|
tracing::debug!("Received cron due debug event: {:?}", notification);
|
||||||
notification,
|
continue;
|
||||||
worker_id,
|
} else if notification.channel() == CRON_DUE_EVENT
|
||||||
retry_duration,
|
&& let Err(e) = cron::Model::handle_cron_notification(
|
||||||
)
|
ctx.as_ref(),
|
||||||
.await
|
notification,
|
||||||
|
worker_id,
|
||||||
|
retry_duration,
|
||||||
|
)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!("Error handling cron notification: {e}");
|
tracing::error!("Error handling cron notification: {e}");
|
||||||
}
|
}
|
||||||
@@ -298,13 +329,21 @@ impl TaskService {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
use rstest::{fixture, rstest};
|
use rstest::{fixture, rstest};
|
||||||
|
use sea_orm::ActiveValue;
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::test_utils::{
|
use crate::{
|
||||||
// app::TestingPreset,
|
models::cron,
|
||||||
tracing::try_init_testing_tracing,
|
task::EchoTask,
|
||||||
|
test_utils::{
|
||||||
|
app::{TestingAppContextConfig, TestingPreset},
|
||||||
|
tracing::try_init_testing_tracing,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[fixture]
|
#[fixture]
|
||||||
@@ -314,7 +353,82 @@ mod tests {
|
|||||||
|
|
||||||
#[rstest]
|
#[rstest]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_cron_due_listening(before_each: ()) -> RecorderResult<()> {
|
#[tracing_test::traced_test]
|
||||||
todo!()
|
async fn test_check_and_trigger_due_crons_with_certain_interval(
|
||||||
|
before_each: (),
|
||||||
|
) -> RecorderResult<()> {
|
||||||
|
let preset = TestingPreset::default_with_config(
|
||||||
|
TestingAppContextConfig::builder()
|
||||||
|
.task_config(TaskConfig {
|
||||||
|
cron_interval_duration: Duration::from_millis(1500),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let app_ctx = preset.app_ctx;
|
||||||
|
let task_service = app_ctx.task();
|
||||||
|
|
||||||
|
let task_id = Uuid::now_v7().to_string();
|
||||||
|
|
||||||
|
let echo_cron = cron::ActiveModel {
|
||||||
|
cron_expr: ActiveValue::Set("*/1 * * * * *".to_string()),
|
||||||
|
cron_timezone: ActiveValue::Set("Asia/Singapore".to_string()),
|
||||||
|
system_task_cron: ActiveValue::Set(Some(
|
||||||
|
EchoTask::builder().task_id(task_id.clone()).build().into(),
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
task_service.add_system_task_cron(echo_cron).await?;
|
||||||
|
|
||||||
|
task_service
|
||||||
|
.run_with_signal(Some(async move || {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
}))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
assert!(logs_contain(&format!(
|
||||||
|
"EchoTask {task_id} start running at"
|
||||||
|
)));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rstest]
|
||||||
|
#[tokio::test]
|
||||||
|
#[tracing_test::traced_test]
|
||||||
|
async fn test_trigger_due_cron_when_mutating(before_each: ()) -> RecorderResult<()> {
|
||||||
|
let preset = TestingPreset::default().await?;
|
||||||
|
let app_ctx = preset.app_ctx;
|
||||||
|
let task_service = app_ctx.task();
|
||||||
|
|
||||||
|
let task_id = Uuid::now_v7().to_string();
|
||||||
|
|
||||||
|
let echo_cron = cron::ActiveModel {
|
||||||
|
cron_expr: ActiveValue::Set("* * * */1 * *".to_string()),
|
||||||
|
cron_timezone: ActiveValue::Set("Asia/Singapore".to_string()),
|
||||||
|
next_run: ActiveValue::Set(Some(Utc::now() + chrono::Duration::seconds(-10))),
|
||||||
|
system_task_cron: ActiveValue::Set(Some(
|
||||||
|
EchoTask::builder().task_id(task_id.clone()).build().into(),
|
||||||
|
)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let task_runner = task_service.run_with_signal(Some(async move || {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||||
|
}));
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
task_service.add_system_task_cron(echo_cron).await?;
|
||||||
|
|
||||||
|
task_runner.await?;
|
||||||
|
|
||||||
|
assert!(logs_contain(&format!(
|
||||||
|
"EchoTask {task_id} start running at"
|
||||||
|
)));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use typed_builder::TypedBuilder;
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::AppContextTrait,
|
app::AppContextTrait,
|
||||||
errors::RecorderResult,
|
errors::RecorderResult,
|
||||||
|
task::TaskConfig,
|
||||||
test_utils::{
|
test_utils::{
|
||||||
crypto::build_testing_crypto_service,
|
crypto::build_testing_crypto_service,
|
||||||
database::{TestingDatabaseServiceConfig, build_testing_database_service},
|
database::{TestingDatabaseServiceConfig, build_testing_database_service},
|
||||||
@@ -43,10 +44,11 @@ impl TestingAppContext {
|
|||||||
self.task.get_or_init(|| task);
|
self.task.get_or_init(|| task);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn from_preset(preset: TestingAppContextPreset) -> RecorderResult<Arc<Self>> {
|
pub async fn from_config(config: TestingAppContextConfig) -> RecorderResult<Arc<Self>> {
|
||||||
let mikan_client = build_testing_mikan_client(preset.mikan_base_url).await?;
|
let mikan_base_url = config.mikan_base_url.expect("mikan_base_url is required");
|
||||||
|
let mikan_client = build_testing_mikan_client(mikan_base_url).await?;
|
||||||
let db_service =
|
let db_service =
|
||||||
build_testing_database_service(preset.database_config.unwrap_or_default()).await?;
|
build_testing_database_service(config.database_config.unwrap_or_default()).await?;
|
||||||
let crypto_service = build_testing_crypto_service().await?;
|
let crypto_service = build_testing_crypto_service().await?;
|
||||||
let storage_service = build_testing_storage_service().await?;
|
let storage_service = build_testing_storage_service().await?;
|
||||||
let media_service = build_testing_media_service().await?;
|
let media_service = build_testing_media_service().await?;
|
||||||
@@ -60,7 +62,7 @@ impl TestingAppContext {
|
|||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let task_service = build_testing_task_service(app_ctx.clone()).await?;
|
let task_service = build_testing_task_service(config.task_config, app_ctx.clone()).await?;
|
||||||
|
|
||||||
app_ctx.set_task(task_service);
|
app_ctx.set_task(task_service);
|
||||||
|
|
||||||
@@ -132,9 +134,12 @@ impl AppContextTrait for TestingAppContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TestingAppContextPreset {
|
#[derive(TypedBuilder, Debug)]
|
||||||
pub mikan_base_url: String,
|
#[builder(field_defaults(default, setter(strip_option)))]
|
||||||
|
pub struct TestingAppContextConfig {
|
||||||
|
pub mikan_base_url: Option<String>,
|
||||||
pub database_config: Option<TestingDatabaseServiceConfig>,
|
pub database_config: Option<TestingDatabaseServiceConfig>,
|
||||||
|
pub task_config: Option<TaskConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(TypedBuilder)]
|
#[derive(TypedBuilder)]
|
||||||
@@ -144,15 +149,15 @@ pub struct TestingPreset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TestingPreset {
|
impl TestingPreset {
|
||||||
pub async fn default() -> RecorderResult<Self> {
|
pub async fn default_with_config(config: TestingAppContextConfig) -> RecorderResult<Self> {
|
||||||
let mikan_server = MikanMockServer::new().await?;
|
let mikan_server = MikanMockServer::new().await?;
|
||||||
let database_config = TestingDatabaseServiceConfig::default();
|
|
||||||
|
|
||||||
let app_ctx = TestingAppContext::from_preset(TestingAppContextPreset {
|
let mixed_config = TestingAppContextConfig {
|
||||||
mikan_base_url: mikan_server.base_url().to_string(),
|
mikan_base_url: Some(mikan_server.base_url().to_string()),
|
||||||
database_config: Some(database_config),
|
..config
|
||||||
})
|
};
|
||||||
.await?;
|
|
||||||
|
let app_ctx = TestingAppContext::from_config(mixed_config).await?;
|
||||||
|
|
||||||
let preset = Self::builder()
|
let preset = Self::builder()
|
||||||
.mikan_server(mikan_server)
|
.mikan_server(mikan_server)
|
||||||
@@ -160,4 +165,13 @@ impl TestingPreset {
|
|||||||
.build();
|
.build();
|
||||||
Ok(preset)
|
Ok(preset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn default() -> RecorderResult<Self> {
|
||||||
|
Self::default_with_config(TestingAppContextConfig {
|
||||||
|
mikan_base_url: None,
|
||||||
|
database_config: None,
|
||||||
|
task_config: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ pub async fn build_testing_database_service(
|
|||||||
uri: connection_string,
|
uri: connection_string,
|
||||||
enable_logging: true,
|
enable_logging: true,
|
||||||
min_connections: 1,
|
min_connections: 1,
|
||||||
max_connections: 1,
|
max_connections: 5,
|
||||||
connect_timeout: 5000,
|
connect_timeout: 5000,
|
||||||
idle_timeout: 10000,
|
idle_timeout: 10000,
|
||||||
acquire_timeout: None,
|
acquire_timeout: None,
|
||||||
|
|||||||
@@ -1,42 +1,16 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::Utc;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppContextTrait,
|
app::AppContextTrait,
|
||||||
errors::RecorderResult,
|
errors::RecorderResult,
|
||||||
task::{AsyncTaskTrait, TaskConfig, TaskService, register_system_task_type},
|
task::{TaskConfig, TaskService},
|
||||||
};
|
};
|
||||||
|
|
||||||
register_system_task_type! {
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct TestSystemTask {
|
|
||||||
pub task_id: String,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl AsyncTaskTrait for TestSystemTask {
|
|
||||||
async fn run_async(self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()> {
|
|
||||||
let storage = ctx.storage();
|
|
||||||
|
|
||||||
storage
|
|
||||||
.write(
|
|
||||||
storage.build_test_path(self.task_id),
|
|
||||||
serde_json::json!({ "exec_time": Utc::now().timestamp_millis() })
|
|
||||||
.to_string()
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn build_testing_task_service(
|
pub async fn build_testing_task_service(
|
||||||
|
config: Option<TaskConfig>,
|
||||||
ctx: Arc<dyn AppContextTrait>,
|
ctx: Arc<dyn AppContextTrait>,
|
||||||
) -> RecorderResult<TaskService> {
|
) -> RecorderResult<TaskService> {
|
||||||
let config = TaskConfig::default();
|
let config = config.unwrap_or_default();
|
||||||
let task_service = TaskService::from_config_and_ctx(config, ctx).await?;
|
let task_service = TaskService::from_config_and_ctx(config, ctx).await?;
|
||||||
|
|
||||||
Ok(task_service)
|
Ok(task_service)
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ fn make_request_id(maybe_request_id: Option<HeaderValue>) -> String {
|
|||||||
});
|
});
|
||||||
id.filter(|s| !s.is_empty())
|
id.filter(|s| !s.is_empty())
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| Uuid::new_v4().to_string())
|
.unwrap_or_else(|| Uuid::now_v7().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const config: CodegenConfig = {
|
|||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
enumsAsConst: true,
|
enumsAsConst: true,
|
||||||
|
useTypeImports: true,
|
||||||
scalars: {
|
scalars: {
|
||||||
SubscriberTaskType: {
|
SubscriberTaskType: {
|
||||||
input: 'recorder/bindings/SubscriberTaskInput#SubscriberTaskInput',
|
input: 'recorder/bindings/SubscriberTaskInput#SubscriberTaskInput',
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { NavMainGroup } from '@/infra/routes/nav';
|
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Folders,
|
Folders,
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
Telescope,
|
Telescope,
|
||||||
Tv,
|
Tv,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import type { NavMainGroup } from '@/infra/routes/nav';
|
||||||
|
|
||||||
export const AppNavMainData: NavMainGroup[] = [
|
export const AppNavMainData: NavMainGroup[] = [
|
||||||
{
|
{
|
||||||
@@ -49,13 +49,13 @@ export const AppNavMainData: NavMainGroup[] = [
|
|||||||
{
|
{
|
||||||
title: 'Manage',
|
title: 'Manage',
|
||||||
link: {
|
link: {
|
||||||
to: '/bangumi/recorder',
|
to: '/bangumi',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Feed',
|
title: 'Feed',
|
||||||
link: {
|
link: {
|
||||||
to: '/bangumi/feed',
|
to: '/bangumi',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -65,11 +65,17 @@ export const AppNavMainData: NavMainGroup[] = [
|
|||||||
icon: ListTodo,
|
icon: ListTodo,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
title: 'Manage',
|
title: 'Tasks',
|
||||||
link: {
|
link: {
|
||||||
to: '/tasks/manage',
|
to: '/tasks/manage',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Crons',
|
||||||
|
link: {
|
||||||
|
to: '/tasks/cron/manage',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useMatches } from '@tanstack/react-router';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -27,13 +27,8 @@ import {
|
|||||||
useSidebar,
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import type { NavMainGroup, NavMainItem } from '@/infra/routes/nav';
|
import type { NavMainGroup, NavMainItem } from '@/infra/routes/nav';
|
||||||
import { useMatches } from '@tanstack/react-router';
|
|
||||||
|
|
||||||
export function NavMain({
|
export function NavMain({ groups }: { groups: NavMainGroup[] }) {
|
||||||
groups,
|
|
||||||
}: {
|
|
||||||
groups: NavMainGroup[];
|
|
||||||
}) {
|
|
||||||
const matches = useMatches();
|
const matches = useMatches();
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type VariantProps, cva } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/presentation/utils";
|
import { cn } from "@/presentation/utils";
|
||||||
|
|||||||
52
apps/webui/src/components/ui/container-header.tsx
Normal file
52
apps/webui/src/components/ui/container-header.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useCanGoBack, useNavigate, useRouter } from "@tanstack/react-router";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { type ReactNode, memo } from "react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
export interface ContainerHeaderProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
defaultBackTo?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContainerHeader = memo(
|
||||||
|
({ title, description, defaultBackTo, actions }: ContainerHeaderProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const router = useRouter();
|
||||||
|
const canGoBack = useCanGoBack();
|
||||||
|
|
||||||
|
const finalCanGoBack = canGoBack || !!defaultBackTo;
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (canGoBack) {
|
||||||
|
router.history.back();
|
||||||
|
} else {
|
||||||
|
navigate({ to: defaultBackTo });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{finalCanGoBack && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="font-bold text-2xl">{title}</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">{actions}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
291
apps/webui/src/components/ui/cron/README.md
Normal file
291
apps/webui/src/components/ui/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
|
||||||
743
apps/webui/src/components/ui/cron/cron-builder.tsx
Normal file
743
apps/webui/src/components/ui/cron/cron-builder.tsx
Normal file
@@ -0,0 +1,743 @@
|
|||||||
|
import { getFutureMatches } from "@datasert/cronjs-matcher";
|
||||||
|
import { Calendar, Clock, Info, Settings, Zap } from "lucide-react";
|
||||||
|
import {
|
||||||
|
type CSSProperties,
|
||||||
|
type FC,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
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 {
|
||||||
|
type CronBuilderProps,
|
||||||
|
CronField,
|
||||||
|
type CronFieldConfig,
|
||||||
|
CronPeriod,
|
||||||
|
type CronPreset,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
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={(v) => handlePeriodChange(v 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 (
|
||||||
|
<CronFieldItemEditor
|
||||||
|
key={field}
|
||||||
|
config={config}
|
||||||
|
field={field}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CronFieldItemAnyOrSpecificOption = {
|
||||||
|
Any: "any",
|
||||||
|
Specific: "specific",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type CronFieldItemAnyOrSpecificOption =
|
||||||
|
(typeof CronFieldItemAnyOrSpecificOption)[keyof typeof CronFieldItemAnyOrSpecificOption];
|
||||||
|
|
||||||
|
interface CronFieldItemEditorProps {
|
||||||
|
config: CronFieldConfig;
|
||||||
|
field: CronField;
|
||||||
|
value: string;
|
||||||
|
onChange: (field: CronField, value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeCronFieldItem(value: string): string {
|
||||||
|
if (value === "") {
|
||||||
|
return "<meta:empty>";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes(" ")) {
|
||||||
|
return `<meta:contains-space:${encodeURIComponent(value)}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCronFieldItem(value: string): string {
|
||||||
|
if (value.startsWith("<meta:contains")) {
|
||||||
|
return decodeURIComponent(
|
||||||
|
// biome-ignore lint/performance/useTopLevelRegex: false
|
||||||
|
value.replace(/^<meta:contains-space:([^>]+)>$/, "$1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "<meta:empty>") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CronFieldItemEditor: FC<CronFieldItemEditorProps> = memo(
|
||||||
|
({ field, value, onChange, config, disabled = false }) => {
|
||||||
|
const [innerValue, _setInnerValue] = useState(() =>
|
||||||
|
decodeCronFieldItem(value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [anyOrSpecificOption, _setAnyOrSpecificOption] =
|
||||||
|
useState<CronFieldItemAnyOrSpecificOption>(() =>
|
||||||
|
innerValue === "*"
|
||||||
|
? CronFieldItemAnyOrSpecificOption.Any
|
||||||
|
: CronFieldItemAnyOrSpecificOption.Specific
|
||||||
|
);
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: false
|
||||||
|
useEffect(() => {
|
||||||
|
const nextValue = decodeCronFieldItem(value);
|
||||||
|
if (nextValue !== innerValue) {
|
||||||
|
_setInnerValue(nextValue);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(v: string) => {
|
||||||
|
_setInnerValue(v);
|
||||||
|
onChange(field, encodeCronFieldItem(v));
|
||||||
|
},
|
||||||
|
[field, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setAnyOrSpecificOption = useCallback(
|
||||||
|
(v: CronFieldItemAnyOrSpecificOption) => {
|
||||||
|
_setAnyOrSpecificOption(v);
|
||||||
|
if (v === CronFieldItemAnyOrSpecificOption.Any) {
|
||||||
|
handleChange("*");
|
||||||
|
} else if (v === CronFieldItemAnyOrSpecificOption.Specific) {
|
||||||
|
handleChange("0");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div 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={innerValue}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{field === "dayOfMonth" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select
|
||||||
|
value={innerValue}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{!(
|
||||||
|
field === "month" ||
|
||||||
|
field === "dayOfWeek" ||
|
||||||
|
field === "dayOfMonth"
|
||||||
|
) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={anyOrSpecificOption}
|
||||||
|
onValueChange={setAnyOrSpecificOption}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<ToggleGroupItem
|
||||||
|
value={CronFieldItemAnyOrSpecificOption.Any}
|
||||||
|
className="min-w-fit text-xs"
|
||||||
|
>
|
||||||
|
Any
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem
|
||||||
|
value={CronFieldItemAnyOrSpecificOption.Specific}
|
||||||
|
className="min-w-fit text-xs"
|
||||||
|
>
|
||||||
|
Specific
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
{anyOrSpecificOption ===
|
||||||
|
CronFieldItemAnyOrSpecificOption.Specific && (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={innerValue}
|
||||||
|
onChange={(e) => handleChange(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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/ui/cron/cron-display.tsx
Normal file
277
apps/webui/src/components/ui/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/ui/cron/cron-example.tsx
Normal file
413
apps/webui/src/components/ui/cron/cron-example.tsx
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
import { Code2, Play, Settings, Type } from "lucide-react";
|
||||||
|
import { type FC, useCallback, useState } from "react";
|
||||||
|
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 { Cron } from "./cron.jsx";
|
||||||
|
import { CronBuilder } from "./cron-builder.jsx";
|
||||||
|
import { CronDisplay } from "./cron-display.jsx";
|
||||||
|
import { CronInput } from "./cron-input.jsx";
|
||||||
|
|
||||||
|
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 };
|
||||||
190
apps/webui/src/components/ui/cron/cron-input.tsx
Normal file
190
apps/webui/src/components/ui/cron/cron-input.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
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',
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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 };
|
||||||
512
apps/webui/src/components/ui/cron/cron.tsx
Normal file
512
apps/webui/src/components/ui/cron/cron.tsx
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
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 { 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 { CronBuilder } from "./cron-builder.js";
|
||||||
|
import { CronDisplay } from "./cron-display.js";
|
||||||
|
import { CronInput } from "./cron-input.js";
|
||||||
|
import {
|
||||||
|
CronMode,
|
||||||
|
type CronPrimitiveMode,
|
||||||
|
type CronProps,
|
||||||
|
type CronValidationResult,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
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,
|
||||||
|
isFirstSibling = false,
|
||||||
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: false
|
||||||
|
}) => {
|
||||||
|
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(
|
||||||
|
(m: CronPrimitiveMode) => {
|
||||||
|
setInternalActiveMode(m);
|
||||||
|
onActiveModeChange?.(m);
|
||||||
|
},
|
||||||
|
[onActiveModeChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
if (!internalValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(internalValue);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to copy to clipboard:", e);
|
||||||
|
}
|
||||||
|
}, [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 shadow-none",
|
||||||
|
!withCard && isFirstSibling && "pt-0"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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={(v) =>
|
||||||
|
handleActiveModeChange(v as "input" | "builder")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger
|
||||||
|
value="input"
|
||||||
|
className="flex min-w-fit items-center gap-1"
|
||||||
|
>
|
||||||
|
<Type className="h-4 w-4" />
|
||||||
|
Text Input
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="builder"
|
||||||
|
className="flex min-w-fit items-center gap-1"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Visual Build
|
||||||
|
</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/ui/cron/index.ts
Normal file
20
apps/webui/src/components/ui/cron/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export { Cron } from "./cron.js";
|
||||||
|
export { CronBuilder } from "./cron-builder.js";
|
||||||
|
export { CronDisplay } from "./cron-display.js";
|
||||||
|
export { CronExample } from "./cron-example.js";
|
||||||
|
export { CronInput } from "./cron-input.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CronBuilderProps,
|
||||||
|
type CronDisplayProps,
|
||||||
|
type CronExpression,
|
||||||
|
CronField,
|
||||||
|
type CronFieldConfig,
|
||||||
|
type CronInputProps,
|
||||||
|
type CronNextRun,
|
||||||
|
CronPeriod,
|
||||||
|
type CronPreset,
|
||||||
|
type CronProps,
|
||||||
|
type CronValidationResult,
|
||||||
|
type PeriodConfig,
|
||||||
|
} from "./types.js";
|
||||||
163
apps/webui/src/components/ui/cron/types.ts
Normal file
163
apps/webui/src/components/ui/cron/types.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
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;
|
||||||
|
isFirstSibling?: 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,20 +1,32 @@
|
|||||||
import { type LinkComponent, createLink } from "@tanstack/react-router";
|
import { createLink, type LinkComponentProps } from "@tanstack/react-router";
|
||||||
import type { AnchorHTMLAttributes, ComponentProps } from "react";
|
import type { AnchorHTMLAttributes } from "react";
|
||||||
|
|
||||||
export interface BasicLinkProps
|
export interface BasicLinkProps
|
||||||
extends AnchorHTMLAttributes<HTMLAnchorElement> {}
|
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
|
||||||
|
href: string;
|
||||||
|
to?: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const BasicLinkComponent = (props: ComponentProps<"a">) => {
|
const BasicLinkComponent = (props: BasicLinkProps) => {
|
||||||
return <a {...props} />;
|
return <a {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CreatedLinkComponent = createLink(BasicLinkComponent);
|
const CreatedLinkComponent = createLink(BasicLinkComponent);
|
||||||
|
|
||||||
export const ProLink: LinkComponent<typeof BasicLinkComponent> = (props) => {
|
export const ProLink = (
|
||||||
|
props: LinkComponentProps<typeof BasicLinkComponent> | BasicLinkProps
|
||||||
|
) => {
|
||||||
if (props.href) {
|
if (props.href) {
|
||||||
return <BasicLinkComponent {...(props as any)} />;
|
return <BasicLinkComponent {...(props as any)} />;
|
||||||
}
|
}
|
||||||
return <CreatedLinkComponent preload={"intent"} {...props} />;
|
return (
|
||||||
|
<CreatedLinkComponent
|
||||||
|
preload={"intent"}
|
||||||
|
{...(props as LinkComponentProps<typeof BasicLinkComponent>)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProLinkProps = ComponentProps<typeof ProLink>;
|
export type ProLinkProps =
|
||||||
|
| LinkComponentProps<typeof BasicLinkComponent>
|
||||||
|
| BasicLinkProps;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
|
|
||||||
export interface QueryErrorViewProps {
|
export interface QueryErrorViewProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
1
apps/webui/src/domains/recorder/index.ts
Normal file
1
apps/webui/src/domains/recorder/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { provideRecorder } from './context';
|
||||||
105
apps/webui/src/domains/recorder/schema/cron.ts
Normal file
105
apps/webui/src/domains/recorder/schema/cron.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
import type { GetCronsQuery } from '@/infra/graphql/gql/graphql';
|
||||||
|
|
||||||
|
export const GET_CRONS = gql`
|
||||||
|
query GetCrons($filter: CronFilterInput!, $orderBy: CronOrderInput!, $pagination: PaginationInput!) {
|
||||||
|
cron(pagination: $pagination, filter: $filter, orderBy: $orderBy) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
cronExpr
|
||||||
|
nextRun
|
||||||
|
lastRun
|
||||||
|
lastError
|
||||||
|
status
|
||||||
|
lockedAt
|
||||||
|
lockedBy
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
timeoutMs
|
||||||
|
maxAttempts
|
||||||
|
priority
|
||||||
|
attempts
|
||||||
|
enabled
|
||||||
|
subscriberTaskCron
|
||||||
|
subscriberTask {
|
||||||
|
nodes {
|
||||||
|
id,
|
||||||
|
job,
|
||||||
|
taskType,
|
||||||
|
status,
|
||||||
|
attempts,
|
||||||
|
maxAttempts,
|
||||||
|
runAt,
|
||||||
|
lastError,
|
||||||
|
lockAt,
|
||||||
|
lockBy,
|
||||||
|
doneAt,
|
||||||
|
priority,
|
||||||
|
subscription {
|
||||||
|
displayName
|
||||||
|
sourceUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paginationInfo {
|
||||||
|
total
|
||||||
|
pages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type CronDto = GetCronsQuery['cron']['nodes'][number];
|
||||||
|
|
||||||
|
export const DELETE_CRONS = gql`
|
||||||
|
mutation DeleteCrons($filter: CronFilterInput!) {
|
||||||
|
cronDelete(filter: $filter)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_CRONS = gql`
|
||||||
|
mutation UpdateCrons($filter: CronFilterInput!, $data: CronUpdateInput!) {
|
||||||
|
cronUpdate(filter: $filter, data: $data) {
|
||||||
|
id
|
||||||
|
cronExpr
|
||||||
|
nextRun
|
||||||
|
lastRun
|
||||||
|
lastError
|
||||||
|
status
|
||||||
|
lockedAt
|
||||||
|
lockedBy
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
timeoutMs
|
||||||
|
enabled
|
||||||
|
maxAttempts
|
||||||
|
priority
|
||||||
|
attempts
|
||||||
|
subscriberTaskCron
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const INSERT_CRON = gql`
|
||||||
|
mutation InsertCron($data: CronInsertInput!) {
|
||||||
|
cronCreateOne(data: $data) {
|
||||||
|
id
|
||||||
|
cronExpr
|
||||||
|
nextRun
|
||||||
|
lastRun
|
||||||
|
lastError
|
||||||
|
status
|
||||||
|
lockedAt
|
||||||
|
lockedBy
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
enabled
|
||||||
|
timeoutMs
|
||||||
|
maxAttempts
|
||||||
|
priority
|
||||||
|
attempts
|
||||||
|
subscriberTaskCron
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
|
import { gql } from '@apollo/client';
|
||||||
|
import { type } from 'arktype';
|
||||||
import { arkValidatorToTypeNarrower } from '@/infra/errors/arktype';
|
import { arkValidatorToTypeNarrower } from '@/infra/errors/arktype';
|
||||||
import {
|
import {
|
||||||
type GetSubscriptionsQuery,
|
type GetSubscriptionsQuery,
|
||||||
SubscriptionCategoryEnum,
|
SubscriptionCategoryEnum,
|
||||||
} from '@/infra/graphql/gql/graphql';
|
} from '@/infra/graphql/gql/graphql';
|
||||||
import { gql } from '@apollo/client';
|
|
||||||
import { type } from 'arktype';
|
|
||||||
import {
|
import {
|
||||||
|
extractMikanSubscriptionBangumiSourceUrl,
|
||||||
|
extractMikanSubscriptionSubscriberSourceUrl,
|
||||||
MikanSubscriptionBangumiSourceUrlSchema,
|
MikanSubscriptionBangumiSourceUrlSchema,
|
||||||
MikanSubscriptionSeasonSourceUrlSchema,
|
MikanSubscriptionSeasonSourceUrlSchema,
|
||||||
MikanSubscriptionSubscriberSourceUrlSchema,
|
MikanSubscriptionSubscriberSourceUrlSchema,
|
||||||
extractMikanSubscriptionBangumiSourceUrl,
|
|
||||||
extractMikanSubscriptionSubscriberSourceUrl,
|
|
||||||
} from './mikan';
|
} from './mikan';
|
||||||
|
|
||||||
export const GET_SUBSCRIPTIONS = gql`
|
export const GET_SUBSCRIPTIONS = gql`
|
||||||
@@ -83,10 +83,8 @@ export const DELETE_SUBSCRIPTIONS = gql`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const GET_SUBSCRIPTION_DETAIL = gql`
|
export const GET_SUBSCRIPTION_DETAIL = gql`
|
||||||
query GetSubscriptionDetail ($id: Int!) {
|
query GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {
|
||||||
subscriptions(filter: { id: {
|
subscriptions(filter: $filter) {
|
||||||
eq: $id
|
|
||||||
} }) {
|
|
||||||
nodes {
|
nodes {
|
||||||
id
|
id
|
||||||
subscriberId
|
subscriberId
|
||||||
@@ -106,7 +104,15 @@ query GetSubscriptionDetail ($id: Int!) {
|
|||||||
feedSource
|
feedSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
subscriberTask {
|
subscriberTask(pagination: {
|
||||||
|
page: {
|
||||||
|
page: 0,
|
||||||
|
limit: 3,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
runAt: DESC,
|
||||||
|
}) {
|
||||||
nodes {
|
nodes {
|
||||||
id
|
id
|
||||||
taskType
|
taskType
|
||||||
@@ -117,6 +123,34 @@ query GetSubscriptionDetail ($id: Int!) {
|
|||||||
id
|
id
|
||||||
username
|
username
|
||||||
}
|
}
|
||||||
|
cron (pagination: {
|
||||||
|
page: {
|
||||||
|
page: 0,
|
||||||
|
limit: 3,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: DESC,
|
||||||
|
}) {
|
||||||
|
nodes {
|
||||||
|
id
|
||||||
|
cronExpr
|
||||||
|
nextRun
|
||||||
|
lastRun
|
||||||
|
lastError
|
||||||
|
enabled
|
||||||
|
status
|
||||||
|
lockedAt
|
||||||
|
lockedBy
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
timeoutMs
|
||||||
|
maxAttempts
|
||||||
|
priority
|
||||||
|
attempts
|
||||||
|
subscriberTaskCron
|
||||||
|
}
|
||||||
|
}
|
||||||
bangumi {
|
bangumi {
|
||||||
nodes {
|
nodes {
|
||||||
createdAt
|
createdAt
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { GetTasksQuery } from '@/infra/graphql/gql/graphql';
|
|
||||||
import { gql } from '@apollo/client';
|
import { gql } from '@apollo/client';
|
||||||
|
import type { GetTasksQuery } from '@/infra/graphql/gql/graphql';
|
||||||
|
|
||||||
export const GET_TASKS = gql`
|
export const GET_TASKS = gql`
|
||||||
query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {
|
query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {
|
||||||
@@ -25,6 +25,22 @@ export const GET_TASKS = gql`
|
|||||||
displayName
|
displayName
|
||||||
sourceUrl
|
sourceUrl
|
||||||
}
|
}
|
||||||
|
cron {
|
||||||
|
id
|
||||||
|
cronExpr
|
||||||
|
nextRun
|
||||||
|
lastRun
|
||||||
|
lastError
|
||||||
|
status
|
||||||
|
lockedAt
|
||||||
|
lockedBy
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
timeoutMs
|
||||||
|
maxAttempts
|
||||||
|
priority
|
||||||
|
attempts
|
||||||
|
}
|
||||||
}
|
}
|
||||||
paginationInfo {
|
paginationInfo {
|
||||||
total
|
total
|
||||||
|
|||||||
30
apps/webui/src/infra/forms/compat.ts
Normal file
30
apps/webui/src/infra/forms/compat.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
type AllKeys<T> = T extends any ? keyof T : never;
|
||||||
|
|
||||||
|
type ToDefaultable<T> = Exclude<
|
||||||
|
T extends string | undefined
|
||||||
|
? T | ''
|
||||||
|
: T extends number | undefined
|
||||||
|
? T | number
|
||||||
|
: T extends undefined
|
||||||
|
? T | null
|
||||||
|
: T,
|
||||||
|
undefined
|
||||||
|
>;
|
||||||
|
|
||||||
|
type PickFieldFormUnion<T, K extends keyof T> = T extends any
|
||||||
|
? T[keyof T & K]
|
||||||
|
: never;
|
||||||
|
|
||||||
|
// compact more types;
|
||||||
|
export type FormDefaultValues<T> = {
|
||||||
|
-readonly [K in AllKeys<T>]-?: ToDefaultable<PickFieldFormUnion<T, K>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/shadcn-ui/ui/issues/427
|
||||||
|
*/
|
||||||
|
export function compatFormDefaultValues<T, K extends AllKeys<T> = AllKeys<T>>(
|
||||||
|
d: FormDefaultValues<Pick<T, K>>
|
||||||
|
): T {
|
||||||
|
return d as unknown as T;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
|
import type { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
|
||||||
import { FragmentDefinitionNode } from 'graphql';
|
import type { FragmentDefinitionNode } from 'graphql';
|
||||||
import { Incremental } from './graphql';
|
import type { Incremental } from './graphql';
|
||||||
|
|
||||||
|
|
||||||
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration<
|
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration<
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import * as types from './graphql';
|
import * as types from './graphql';
|
||||||
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map of all GraphQL operations in the project.
|
* Map of all GraphQL operations in the project.
|
||||||
@@ -20,14 +20,18 @@ type Documents = {
|
|||||||
"\n mutation DeleteCredential3rd($filter: Credential3rdFilterInput!) {\n credential3rdDelete(filter: $filter)\n }\n": typeof types.DeleteCredential3rdDocument,
|
"\n mutation DeleteCredential3rd($filter: Credential3rdFilterInput!) {\n credential3rdDelete(filter: $filter)\n }\n": typeof types.DeleteCredential3rdDocument,
|
||||||
"\n query GetCredential3rdDetail($id: Int!) {\n credential3rd(filter: { id: { eq: $id } }) {\n nodes {\n id\n cookies\n username\n password\n userAgent\n createdAt\n updatedAt\n credentialType\n }\n }\n }\n": typeof types.GetCredential3rdDetailDocument,
|
"\n query GetCredential3rdDetail($id: Int!) {\n credential3rd(filter: { id: { eq: $id } }) {\n nodes {\n id\n cookies\n username\n password\n userAgent\n createdAt\n updatedAt\n credentialType\n }\n }\n }\n": typeof types.GetCredential3rdDetailDocument,
|
||||||
"\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 enabled\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 UpdateCrons($filter: CronFilterInput!, $data: CronUpdateInput!) {\n cronUpdate(filter: $filter, 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 enabled\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n": typeof types.UpdateCronsDocument,
|
||||||
|
"\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 enabled\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,
|
||||||
"\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": typeof types.InsertSubscriptionDocument,
|
"\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": typeof types.InsertSubscriptionDocument,
|
||||||
"\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filter: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filter\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": typeof types.UpdateSubscriptionsDocument,
|
"\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filter: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filter\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": typeof types.UpdateSubscriptionsDocument,
|
||||||
"\n mutation DeleteSubscriptions($filter: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filter)\n }\n": typeof types.DeleteSubscriptionsDocument,
|
"\n mutation DeleteSubscriptions($filter: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filter)\n }\n": typeof types.DeleteSubscriptionsDocument,
|
||||||
"\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": typeof types.GetSubscriptionDetailDocument,
|
"\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": typeof types.GetSubscriptionDetailDocument,
|
||||||
"\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\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 paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetTasksDocument,
|
"\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\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 cron {\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 }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": typeof types.GetTasksDocument,
|
||||||
"\n mutation InsertSubscriberTask($data: SubscriberTasksInsertInput!) {\n subscriberTasksCreateOne(data: $data) {\n id\n }\n }\n": typeof types.InsertSubscriberTaskDocument,
|
"\n mutation InsertSubscriberTask($data: SubscriberTasksInsertInput!) {\n subscriberTasksCreateOne(data: $data) {\n id\n }\n }\n": typeof types.InsertSubscriberTaskDocument,
|
||||||
"\n mutation DeleteTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksDelete(filter: $filter)\n }\n": typeof types.DeleteTasksDocument,
|
"\n mutation DeleteTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksDelete(filter: $filter)\n }\n": typeof types.DeleteTasksDocument,
|
||||||
"\n mutation RetryTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksRetryOne(filter: $filter) {\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 }\n }\n": typeof types.RetryTasksDocument,
|
"\n mutation RetryTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksRetryOne(filter: $filter) {\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 }\n }\n": typeof types.RetryTasksDocument,
|
||||||
@@ -39,14 +43,18 @@ const documents: Documents = {
|
|||||||
"\n mutation DeleteCredential3rd($filter: Credential3rdFilterInput!) {\n credential3rdDelete(filter: $filter)\n }\n": types.DeleteCredential3rdDocument,
|
"\n mutation DeleteCredential3rd($filter: Credential3rdFilterInput!) {\n credential3rdDelete(filter: $filter)\n }\n": types.DeleteCredential3rdDocument,
|
||||||
"\n query GetCredential3rdDetail($id: Int!) {\n credential3rd(filter: { id: { eq: $id } }) {\n nodes {\n id\n cookies\n username\n password\n userAgent\n createdAt\n updatedAt\n credentialType\n }\n }\n }\n": types.GetCredential3rdDetailDocument,
|
"\n query GetCredential3rdDetail($id: Int!) {\n credential3rd(filter: { id: { eq: $id } }) {\n nodes {\n id\n cookies\n username\n password\n userAgent\n createdAt\n updatedAt\n credentialType\n }\n }\n }\n": types.GetCredential3rdDetailDocument,
|
||||||
"\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 enabled\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 UpdateCrons($filter: CronFilterInput!, $data: CronUpdateInput!) {\n cronUpdate(filter: $filter, 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 enabled\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n": types.UpdateCronsDocument,
|
||||||
|
"\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 enabled\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,
|
||||||
"\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": types.InsertSubscriptionDocument,
|
"\n mutation InsertSubscription($data: SubscriptionsInsertInput!) {\n subscriptionsCreateOne(data: $data) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n credentialId\n }\n }\n": types.InsertSubscriptionDocument,
|
||||||
"\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filter: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filter\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": types.UpdateSubscriptionsDocument,
|
"\n mutation UpdateSubscriptions(\n $data: SubscriptionsUpdateInput!,\n $filter: SubscriptionsFilterInput!,\n ) {\n subscriptionsUpdate (\n data: $data\n filter: $filter\n ) {\n id\n createdAt\n updatedAt\n displayName\n category\n sourceUrl\n enabled\n }\n}\n": types.UpdateSubscriptionsDocument,
|
||||||
"\n mutation DeleteSubscriptions($filter: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filter)\n }\n": types.DeleteSubscriptionsDocument,
|
"\n mutation DeleteSubscriptions($filter: SubscriptionsFilterInput) {\n subscriptionsDelete(filter: $filter)\n }\n": types.DeleteSubscriptionsDocument,
|
||||||
"\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": types.GetSubscriptionDetailDocument,
|
"\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n": types.GetSubscriptionDetailDocument,
|
||||||
"\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\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 paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetTasksDocument,
|
"\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\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 cron {\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 }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n": types.GetTasksDocument,
|
||||||
"\n mutation InsertSubscriberTask($data: SubscriberTasksInsertInput!) {\n subscriberTasksCreateOne(data: $data) {\n id\n }\n }\n": types.InsertSubscriberTaskDocument,
|
"\n mutation InsertSubscriberTask($data: SubscriberTasksInsertInput!) {\n subscriberTasksCreateOne(data: $data) {\n id\n }\n }\n": types.InsertSubscriberTaskDocument,
|
||||||
"\n mutation DeleteTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksDelete(filter: $filter)\n }\n": types.DeleteTasksDocument,
|
"\n mutation DeleteTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksDelete(filter: $filter)\n }\n": types.DeleteTasksDocument,
|
||||||
"\n mutation RetryTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksRetryOne(filter: $filter) {\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 }\n }\n": types.RetryTasksDocument,
|
"\n mutation RetryTasks($filter: SubscriberTasksFilterInput!) {\n subscriberTasksRetryOne(filter: $filter) {\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 }\n }\n": types.RetryTasksDocument,
|
||||||
@@ -90,6 +98,22 @@ export function gql(source: "\n query GetCredential3rdDetail($id: Int!) {\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.
|
||||||
*/
|
*/
|
||||||
export function gql(source: "\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n"): (typeof documents)["\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n"];
|
export function gql(source: "\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\n }\n"): (typeof documents)["\n mutation CheckCredential3rdAvailable($filter: Credential3rdFilterInput!) {\n credential3rdCheckAvailable(filter: $filter) {\n available\n }\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: "\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 enabled\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 documents)["\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 enabled\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"];
|
||||||
|
/**
|
||||||
|
* 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"];
|
||||||
|
/**
|
||||||
|
* 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 UpdateCrons($filter: CronFilterInput!, $data: CronUpdateInput!) {\n cronUpdate(filter: $filter, 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 enabled\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n"): (typeof documents)["\n mutation UpdateCrons($filter: CronFilterInput!, $data: CronUpdateInput!) {\n cronUpdate(filter: $filter, 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 enabled\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.
|
||||||
|
*/
|
||||||
|
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 enabled\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 enabled\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.
|
||||||
*/
|
*/
|
||||||
@@ -117,11 +141,11 @@ export function gql(source: "\n mutation DeleteSubscriptions($filter: Subscri
|
|||||||
/**
|
/**
|
||||||
* 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: "\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n"): (typeof documents)["\nquery GetSubscriptionDetail ($id: Int!) {\n subscriptions(filter: { id: {\n eq: $id\n } }) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n"];
|
export function gql(source: "\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\n }\n}\n"): (typeof documents)["\nquery GetSubscriptionDetail ($filter: SubscriptionsFilterInput!) {\n subscriptions(filter: $filter) {\n nodes {\n id\n subscriberId\n displayName\n createdAt\n updatedAt\n category\n sourceUrl\n enabled\n feed {\n nodes {\n id\n createdAt\n updatedAt\n token\n feedType\n feedSource\n }\n }\n subscriberTask(pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n runAt: DESC,\n }) {\n nodes {\n id\n taskType\n status\n }\n }\n credential3rd {\n id\n username\n }\n cron (pagination: {\n page: {\n page: 0,\n limit: 3,\n }\n },\n orderBy: {\n createdAt: DESC,\n }) {\n nodes {\n id\n cronExpr\n nextRun\n lastRun\n lastError\n enabled\n status\n lockedAt\n lockedBy\n createdAt\n updatedAt\n timeoutMs\n maxAttempts\n priority\n attempts\n subscriberTaskCron\n }\n }\n bangumi {\n nodes {\n createdAt\n updatedAt\n id\n mikanBangumiId\n displayName\n season\n seasonRaw\n fansub\n mikanFansubId\n rssLink\n posterLink\n homepage\n }\n }\n }\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.
|
||||||
*/
|
*/
|
||||||
export function gql(source: "\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\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 paginationInfo {\n total\n pages\n }\n }\n }\n"): (typeof documents)["\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\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 paginationInfo {\n total\n pages\n }\n }\n }\n"];
|
export function gql(source: "\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\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 cron {\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 }\n }\n paginationInfo {\n total\n pages\n }\n }\n }\n"): (typeof documents)["\n query GetTasks($filter: SubscriberTasksFilterInput!, $orderBy: SubscriberTasksOrderInput!, $pagination: PaginationInput!) {\n subscriberTasks(\n pagination: $pagination\n filter: $filter\n orderBy: $orderBy\n ) {\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 cron {\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 }\n }\n paginationInfo {\n total\n pages\n }\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 one or more lines are too long
@@ -1,8 +1,8 @@
|
|||||||
import { AUTH_PROVIDER } from '@/infra/auth/auth.provider';
|
import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client';
|
||||||
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
|
|
||||||
import { setContext } from '@apollo/client/link/context';
|
import { setContext } from '@apollo/client/link/context';
|
||||||
import { Injectable, inject } from '@outposts/injection-js';
|
import { Injectable, inject } from '@outposts/injection-js';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { AUTH_PROVIDER } from '@/infra/auth/auth.provider';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GraphQLService {
|
export class GraphQLService {
|
||||||
@@ -27,6 +27,8 @@ export class GraphQLService {
|
|||||||
fetchPolicy: 'cache-and-network',
|
fetchPolicy: 'cache-and-network',
|
||||||
nextFetchPolicy: 'network-only',
|
nextFetchPolicy: 'network-only',
|
||||||
errorPolicy: 'all',
|
errorPolicy: 'all',
|
||||||
|
refetchWritePolicy: 'overwrite',
|
||||||
|
initialFetchPolicy: 'cache-and-network',
|
||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
fetchPolicy: 'network-only',
|
fetchPolicy: 'network-only',
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { GraphQLService } from './graphql.service';
|
export { GraphQLService } from './graphql.service';
|
||||||
export { provideGraphql } from './context';
|
export { provideGraphql, graphqlContextFromInjector } from './context';
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { Injector } from '@outposts/injection-js';
|
import type { Injector, Provider } from '@outposts/injection-js';
|
||||||
import { IntlService } from './intl.service';
|
import { IntlService } from './intl.service';
|
||||||
|
|
||||||
|
export function provideIntl(): Provider[] {
|
||||||
|
return [IntlService];
|
||||||
|
}
|
||||||
|
|
||||||
export function intlContextFromInjector(injector: Injector) {
|
export function intlContextFromInjector(injector: Injector) {
|
||||||
const intlService = injector.get(IntlService);
|
const intlService = injector.get(IntlService);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { useInjector } from 'oidc-client-rx/adapters/react';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { intlContextFromInjector } from './context';
|
|
||||||
|
|
||||||
export function useIntl() {
|
|
||||||
const injector = useInjector();
|
|
||||||
|
|
||||||
return useMemo(() => intlContextFromInjector(injector), [injector]);
|
|
||||||
}
|
|
||||||
2
apps/webui/src/infra/intl/index.ts
Normal file
2
apps/webui/src/infra/intl/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { IntlService } from './intl.service';
|
||||||
|
export { intlContextFromInjector, provideIntl } from './context';
|
||||||
@@ -4,7 +4,18 @@ import { DOCUMENT } from '../platform/injection';
|
|||||||
export class IntlService {
|
export class IntlService {
|
||||||
document = inject(DOCUMENT);
|
document = inject(DOCUMENT);
|
||||||
|
|
||||||
formatTimestamp(timestamp: number, options?: Intl.DateTimeFormatOptions) {
|
get Intl(): typeof Intl {
|
||||||
|
return this.document.defaultView?.Intl as typeof Intl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get timezone() {
|
||||||
|
return this.Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDatetimeWithTz(
|
||||||
|
timestamp: number | string | Date,
|
||||||
|
options?: Intl.DateTimeFormatOptions
|
||||||
|
) {
|
||||||
const defaultOptions: Intl.DateTimeFormatOptions = {
|
const defaultOptions: Intl.DateTimeFormatOptions = {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@@ -16,7 +27,7 @@ export class IntlService {
|
|||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Intl.DateTimeFormat(
|
return new this.Intl.DateTimeFormat(
|
||||||
this.document.defaultView?.navigator.language,
|
this.document.defaultView?.navigator.language,
|
||||||
{
|
{
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ProLinkProps } from '@/components/ui/pro-link';
|
|
||||||
import { type } from 'arktype';
|
import { type } from 'arktype';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import type { ProLinkProps } from '@/components/ui/pro-link';
|
||||||
|
|
||||||
export interface NavMainItem {
|
export interface NavMainItem {
|
||||||
link?: ProLinkProps;
|
link?: ProLinkProps;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { guardRouteIndexAsNotFound } from '@/components/layout/app-not-found';
|
import { guardRouteIndexAsNotFound } from '@/components/layout/app-not-found';
|
||||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
import { Outlet } from '@tanstack/react-router';
|
import { Outlet, type RouteOptions } from '@tanstack/react-router';
|
||||||
|
|
||||||
export interface BuildVirtualBranchRouteOptions {
|
export interface BuildVirtualBranchRouteOptions {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -8,7 +8,11 @@ export interface BuildVirtualBranchRouteOptions {
|
|||||||
|
|
||||||
export function buildVirtualBranchRouteOptions(
|
export function buildVirtualBranchRouteOptions(
|
||||||
options: BuildVirtualBranchRouteOptions
|
options: BuildVirtualBranchRouteOptions
|
||||||
) {
|
): {
|
||||||
|
beforeLoad: RouteOptions['beforeLoad'];
|
||||||
|
staticData: RouteStateDataOption;
|
||||||
|
component: RouteOptions['component'];
|
||||||
|
} {
|
||||||
return {
|
return {
|
||||||
beforeLoad: guardRouteIndexAsNotFound,
|
beforeLoad: guardRouteIndexAsNotFound,
|
||||||
staticData: {
|
staticData: {
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import {
|
|||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
import { provideRecorder } from '@/domains/recorder/context';
|
import { provideRecorder } from '@/domains/recorder';
|
||||||
import { provideGraphql } from '@/infra/graphql';
|
import { graphqlContextFromInjector, provideGraphql } from '@/infra/graphql';
|
||||||
import { graphqlContextFromInjector } from '@/infra/graphql/context';
|
import { provideIntl } from '@/infra/intl';
|
||||||
import { ApolloProvider } from '@apollo/client';
|
import { ApolloProvider } from '@apollo/client';
|
||||||
|
|
||||||
// Create a new router instance
|
// Create a new router instance
|
||||||
@@ -46,6 +46,7 @@ const injector: Injector = ReflectiveInjector.resolveAndCreate([
|
|||||||
...provideStyles(),
|
...provideStyles(),
|
||||||
...provideGraphql(),
|
...provideGraphql(),
|
||||||
...provideRecorder(),
|
...provideRecorder(),
|
||||||
|
...provideIntl(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setupAuthContext(injector);
|
setupAuthContext(injector);
|
||||||
|
|||||||
@@ -31,11 +31,15 @@ import { Route as AppCredential3rdManageRouteImport } from './routes/_app/creden
|
|||||||
import { Route as AppCredential3rdCreateRouteImport } from './routes/_app/credential3rd/create'
|
import { Route as AppCredential3rdCreateRouteImport } from './routes/_app/credential3rd/create'
|
||||||
import { Route as AppBangumiManageRouteImport } from './routes/_app/bangumi/manage'
|
import { Route as AppBangumiManageRouteImport } from './routes/_app/bangumi/manage'
|
||||||
import { Route as AppExploreExploreRouteImport } from './routes/_app/_explore/explore'
|
import { Route as AppExploreExploreRouteImport } from './routes/_app/_explore/explore'
|
||||||
|
import { Route as AppTasksCronRouteRouteImport } from './routes/_app/tasks/cron/route'
|
||||||
import { Route as AppTasksDetailIdRouteImport } from './routes/_app/tasks/detail.$id'
|
import { Route as AppTasksDetailIdRouteImport } from './routes/_app/tasks/detail.$id'
|
||||||
|
import { Route as AppTasksCronManageRouteImport } from './routes/_app/tasks/cron/manage'
|
||||||
import { Route as AppSubscriptionsEditIdRouteImport } from './routes/_app/subscriptions/edit.$id'
|
import { Route as AppSubscriptionsEditIdRouteImport } from './routes/_app/subscriptions/edit.$id'
|
||||||
import { Route as AppSubscriptionsDetailIdRouteImport } from './routes/_app/subscriptions/detail.$id'
|
import { Route as AppSubscriptionsDetailIdRouteImport } from './routes/_app/subscriptions/detail.$id'
|
||||||
import { Route as AppCredential3rdEditIdRouteImport } from './routes/_app/credential3rd/edit.$id'
|
import { Route as AppCredential3rdEditIdRouteImport } from './routes/_app/credential3rd/edit.$id'
|
||||||
import { Route as AppCredential3rdDetailIdRouteImport } from './routes/_app/credential3rd/detail.$id'
|
import { Route as AppCredential3rdDetailIdRouteImport } from './routes/_app/credential3rd/detail.$id'
|
||||||
|
import { Route as AppTasksCronEditIdRouteImport } from './routes/_app/tasks/cron/edit.$id'
|
||||||
|
import { Route as AppTasksCronDetailIdRouteImport } from './routes/_app/tasks/cron/detail.$id'
|
||||||
|
|
||||||
const AboutRoute = AboutRouteImport.update({
|
const AboutRoute = AboutRouteImport.update({
|
||||||
id: '/about',
|
id: '/about',
|
||||||
@@ -148,11 +152,21 @@ const AppExploreExploreRoute = AppExploreExploreRouteImport.update({
|
|||||||
path: '/explore',
|
path: '/explore',
|
||||||
getParentRoute: () => AppRouteRoute,
|
getParentRoute: () => AppRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppTasksCronRouteRoute = AppTasksCronRouteRouteImport.update({
|
||||||
|
id: '/cron',
|
||||||
|
path: '/cron',
|
||||||
|
getParentRoute: () => AppTasksRouteRoute,
|
||||||
|
} as any)
|
||||||
const AppTasksDetailIdRoute = AppTasksDetailIdRouteImport.update({
|
const AppTasksDetailIdRoute = AppTasksDetailIdRouteImport.update({
|
||||||
id: '/detail/$id',
|
id: '/detail/$id',
|
||||||
path: '/detail/$id',
|
path: '/detail/$id',
|
||||||
getParentRoute: () => AppTasksRouteRoute,
|
getParentRoute: () => AppTasksRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppTasksCronManageRoute = AppTasksCronManageRouteImport.update({
|
||||||
|
id: '/manage',
|
||||||
|
path: '/manage',
|
||||||
|
getParentRoute: () => AppTasksCronRouteRoute,
|
||||||
|
} as any)
|
||||||
const AppSubscriptionsEditIdRoute = AppSubscriptionsEditIdRouteImport.update({
|
const AppSubscriptionsEditIdRoute = AppSubscriptionsEditIdRouteImport.update({
|
||||||
id: '/edit/$id',
|
id: '/edit/$id',
|
||||||
path: '/edit/$id',
|
path: '/edit/$id',
|
||||||
@@ -175,6 +189,16 @@ const AppCredential3rdDetailIdRoute =
|
|||||||
path: '/detail/$id',
|
path: '/detail/$id',
|
||||||
getParentRoute: () => AppCredential3rdRouteRoute,
|
getParentRoute: () => AppCredential3rdRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppTasksCronEditIdRoute = AppTasksCronEditIdRouteImport.update({
|
||||||
|
id: '/edit/$id',
|
||||||
|
path: '/edit/$id',
|
||||||
|
getParentRoute: () => AppTasksCronRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const AppTasksCronDetailIdRoute = AppTasksCronDetailIdRouteImport.update({
|
||||||
|
id: '/detail/$id',
|
||||||
|
path: '/detail/$id',
|
||||||
|
getParentRoute: () => AppTasksCronRouteRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -189,6 +213,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/tasks': typeof AppTasksRouteRouteWithChildren
|
'/tasks': typeof AppTasksRouteRouteWithChildren
|
||||||
'/auth/sign-in': typeof AuthSignInRoute
|
'/auth/sign-in': typeof AuthSignInRoute
|
||||||
'/auth/sign-up': typeof AuthSignUpRoute
|
'/auth/sign-up': typeof AuthSignUpRoute
|
||||||
|
'/tasks/cron': typeof AppTasksCronRouteRouteWithChildren
|
||||||
'/explore': typeof AppExploreExploreRoute
|
'/explore': typeof AppExploreExploreRoute
|
||||||
'/bangumi/manage': typeof AppBangumiManageRoute
|
'/bangumi/manage': typeof AppBangumiManageRoute
|
||||||
'/credential3rd/create': typeof AppCredential3rdCreateRoute
|
'/credential3rd/create': typeof AppCredential3rdCreateRoute
|
||||||
@@ -203,7 +228,10 @@ export interface FileRoutesByFullPath {
|
|||||||
'/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute
|
'/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute
|
||||||
'/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute
|
'/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute
|
||||||
'/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute
|
'/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute
|
||||||
|
'/tasks/cron/manage': typeof AppTasksCronManageRoute
|
||||||
'/tasks/detail/$id': typeof AppTasksDetailIdRoute
|
'/tasks/detail/$id': typeof AppTasksDetailIdRoute
|
||||||
|
'/tasks/cron/detail/$id': typeof AppTasksCronDetailIdRoute
|
||||||
|
'/tasks/cron/edit/$id': typeof AppTasksCronEditIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -218,6 +246,7 @@ export interface FileRoutesByTo {
|
|||||||
'/tasks': typeof AppTasksRouteRouteWithChildren
|
'/tasks': typeof AppTasksRouteRouteWithChildren
|
||||||
'/auth/sign-in': typeof AuthSignInRoute
|
'/auth/sign-in': typeof AuthSignInRoute
|
||||||
'/auth/sign-up': typeof AuthSignUpRoute
|
'/auth/sign-up': typeof AuthSignUpRoute
|
||||||
|
'/tasks/cron': typeof AppTasksCronRouteRouteWithChildren
|
||||||
'/explore': typeof AppExploreExploreRoute
|
'/explore': typeof AppExploreExploreRoute
|
||||||
'/bangumi/manage': typeof AppBangumiManageRoute
|
'/bangumi/manage': typeof AppBangumiManageRoute
|
||||||
'/credential3rd/create': typeof AppCredential3rdCreateRoute
|
'/credential3rd/create': typeof AppCredential3rdCreateRoute
|
||||||
@@ -232,7 +261,10 @@ export interface FileRoutesByTo {
|
|||||||
'/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute
|
'/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute
|
||||||
'/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute
|
'/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute
|
||||||
'/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute
|
'/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute
|
||||||
|
'/tasks/cron/manage': typeof AppTasksCronManageRoute
|
||||||
'/tasks/detail/$id': typeof AppTasksDetailIdRoute
|
'/tasks/detail/$id': typeof AppTasksDetailIdRoute
|
||||||
|
'/tasks/cron/detail/$id': typeof AppTasksCronDetailIdRoute
|
||||||
|
'/tasks/cron/edit/$id': typeof AppTasksCronEditIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -248,6 +280,7 @@ export interface FileRoutesById {
|
|||||||
'/_app/tasks': typeof AppTasksRouteRouteWithChildren
|
'/_app/tasks': typeof AppTasksRouteRouteWithChildren
|
||||||
'/auth/sign-in': typeof AuthSignInRoute
|
'/auth/sign-in': typeof AuthSignInRoute
|
||||||
'/auth/sign-up': typeof AuthSignUpRoute
|
'/auth/sign-up': typeof AuthSignUpRoute
|
||||||
|
'/_app/tasks/cron': typeof AppTasksCronRouteRouteWithChildren
|
||||||
'/_app/_explore/explore': typeof AppExploreExploreRoute
|
'/_app/_explore/explore': typeof AppExploreExploreRoute
|
||||||
'/_app/bangumi/manage': typeof AppBangumiManageRoute
|
'/_app/bangumi/manage': typeof AppBangumiManageRoute
|
||||||
'/_app/credential3rd/create': typeof AppCredential3rdCreateRoute
|
'/_app/credential3rd/create': typeof AppCredential3rdCreateRoute
|
||||||
@@ -262,7 +295,10 @@ export interface FileRoutesById {
|
|||||||
'/_app/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute
|
'/_app/credential3rd/edit/$id': typeof AppCredential3rdEditIdRoute
|
||||||
'/_app/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute
|
'/_app/subscriptions/detail/$id': typeof AppSubscriptionsDetailIdRoute
|
||||||
'/_app/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute
|
'/_app/subscriptions/edit/$id': typeof AppSubscriptionsEditIdRoute
|
||||||
|
'/_app/tasks/cron/manage': typeof AppTasksCronManageRoute
|
||||||
'/_app/tasks/detail/$id': typeof AppTasksDetailIdRoute
|
'/_app/tasks/detail/$id': typeof AppTasksDetailIdRoute
|
||||||
|
'/_app/tasks/cron/detail/$id': typeof AppTasksCronDetailIdRoute
|
||||||
|
'/_app/tasks/cron/edit/$id': typeof AppTasksCronEditIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -279,6 +315,7 @@ export interface FileRouteTypes {
|
|||||||
| '/tasks'
|
| '/tasks'
|
||||||
| '/auth/sign-in'
|
| '/auth/sign-in'
|
||||||
| '/auth/sign-up'
|
| '/auth/sign-up'
|
||||||
|
| '/tasks/cron'
|
||||||
| '/explore'
|
| '/explore'
|
||||||
| '/bangumi/manage'
|
| '/bangumi/manage'
|
||||||
| '/credential3rd/create'
|
| '/credential3rd/create'
|
||||||
@@ -293,7 +330,10 @@ export interface FileRouteTypes {
|
|||||||
| '/credential3rd/edit/$id'
|
| '/credential3rd/edit/$id'
|
||||||
| '/subscriptions/detail/$id'
|
| '/subscriptions/detail/$id'
|
||||||
| '/subscriptions/edit/$id'
|
| '/subscriptions/edit/$id'
|
||||||
|
| '/tasks/cron/manage'
|
||||||
| '/tasks/detail/$id'
|
| '/tasks/detail/$id'
|
||||||
|
| '/tasks/cron/detail/$id'
|
||||||
|
| '/tasks/cron/edit/$id'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
@@ -308,6 +348,7 @@ export interface FileRouteTypes {
|
|||||||
| '/tasks'
|
| '/tasks'
|
||||||
| '/auth/sign-in'
|
| '/auth/sign-in'
|
||||||
| '/auth/sign-up'
|
| '/auth/sign-up'
|
||||||
|
| '/tasks/cron'
|
||||||
| '/explore'
|
| '/explore'
|
||||||
| '/bangumi/manage'
|
| '/bangumi/manage'
|
||||||
| '/credential3rd/create'
|
| '/credential3rd/create'
|
||||||
@@ -322,7 +363,10 @@ export interface FileRouteTypes {
|
|||||||
| '/credential3rd/edit/$id'
|
| '/credential3rd/edit/$id'
|
||||||
| '/subscriptions/detail/$id'
|
| '/subscriptions/detail/$id'
|
||||||
| '/subscriptions/edit/$id'
|
| '/subscriptions/edit/$id'
|
||||||
|
| '/tasks/cron/manage'
|
||||||
| '/tasks/detail/$id'
|
| '/tasks/detail/$id'
|
||||||
|
| '/tasks/cron/detail/$id'
|
||||||
|
| '/tasks/cron/edit/$id'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -337,6 +381,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/tasks'
|
| '/_app/tasks'
|
||||||
| '/auth/sign-in'
|
| '/auth/sign-in'
|
||||||
| '/auth/sign-up'
|
| '/auth/sign-up'
|
||||||
|
| '/_app/tasks/cron'
|
||||||
| '/_app/_explore/explore'
|
| '/_app/_explore/explore'
|
||||||
| '/_app/bangumi/manage'
|
| '/_app/bangumi/manage'
|
||||||
| '/_app/credential3rd/create'
|
| '/_app/credential3rd/create'
|
||||||
@@ -351,7 +396,10 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/credential3rd/edit/$id'
|
| '/_app/credential3rd/edit/$id'
|
||||||
| '/_app/subscriptions/detail/$id'
|
| '/_app/subscriptions/detail/$id'
|
||||||
| '/_app/subscriptions/edit/$id'
|
| '/_app/subscriptions/edit/$id'
|
||||||
|
| '/_app/tasks/cron/manage'
|
||||||
| '/_app/tasks/detail/$id'
|
| '/_app/tasks/detail/$id'
|
||||||
|
| '/_app/tasks/cron/detail/$id'
|
||||||
|
| '/_app/tasks/cron/edit/$id'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -520,6 +568,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppExploreExploreRouteImport
|
preLoaderRoute: typeof AppExploreExploreRouteImport
|
||||||
parentRoute: typeof AppRouteRoute
|
parentRoute: typeof AppRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_app/tasks/cron': {
|
||||||
|
id: '/_app/tasks/cron'
|
||||||
|
path: '/cron'
|
||||||
|
fullPath: '/tasks/cron'
|
||||||
|
preLoaderRoute: typeof AppTasksCronRouteRouteImport
|
||||||
|
parentRoute: typeof AppTasksRouteRoute
|
||||||
|
}
|
||||||
'/_app/tasks/detail/$id': {
|
'/_app/tasks/detail/$id': {
|
||||||
id: '/_app/tasks/detail/$id'
|
id: '/_app/tasks/detail/$id'
|
||||||
path: '/detail/$id'
|
path: '/detail/$id'
|
||||||
@@ -527,6 +582,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppTasksDetailIdRouteImport
|
preLoaderRoute: typeof AppTasksDetailIdRouteImport
|
||||||
parentRoute: typeof AppTasksRouteRoute
|
parentRoute: typeof AppTasksRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_app/tasks/cron/manage': {
|
||||||
|
id: '/_app/tasks/cron/manage'
|
||||||
|
path: '/manage'
|
||||||
|
fullPath: '/tasks/cron/manage'
|
||||||
|
preLoaderRoute: typeof AppTasksCronManageRouteImport
|
||||||
|
parentRoute: typeof AppTasksCronRouteRoute
|
||||||
|
}
|
||||||
'/_app/subscriptions/edit/$id': {
|
'/_app/subscriptions/edit/$id': {
|
||||||
id: '/_app/subscriptions/edit/$id'
|
id: '/_app/subscriptions/edit/$id'
|
||||||
path: '/edit/$id'
|
path: '/edit/$id'
|
||||||
@@ -555,6 +617,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppCredential3rdDetailIdRouteImport
|
preLoaderRoute: typeof AppCredential3rdDetailIdRouteImport
|
||||||
parentRoute: typeof AppCredential3rdRouteRoute
|
parentRoute: typeof AppCredential3rdRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_app/tasks/cron/edit/$id': {
|
||||||
|
id: '/_app/tasks/cron/edit/$id'
|
||||||
|
path: '/edit/$id'
|
||||||
|
fullPath: '/tasks/cron/edit/$id'
|
||||||
|
preLoaderRoute: typeof AppTasksCronEditIdRouteImport
|
||||||
|
parentRoute: typeof AppTasksCronRouteRoute
|
||||||
|
}
|
||||||
|
'/_app/tasks/cron/detail/$id': {
|
||||||
|
id: '/_app/tasks/cron/detail/$id'
|
||||||
|
path: '/detail/$id'
|
||||||
|
fullPath: '/tasks/cron/detail/$id'
|
||||||
|
preLoaderRoute: typeof AppTasksCronDetailIdRouteImport
|
||||||
|
parentRoute: typeof AppTasksCronRouteRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,12 +706,29 @@ const AppSubscriptionsRouteRouteWithChildren =
|
|||||||
AppSubscriptionsRouteRouteChildren,
|
AppSubscriptionsRouteRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface AppTasksCronRouteRouteChildren {
|
||||||
|
AppTasksCronManageRoute: typeof AppTasksCronManageRoute
|
||||||
|
AppTasksCronDetailIdRoute: typeof AppTasksCronDetailIdRoute
|
||||||
|
AppTasksCronEditIdRoute: typeof AppTasksCronEditIdRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppTasksCronRouteRouteChildren: AppTasksCronRouteRouteChildren = {
|
||||||
|
AppTasksCronManageRoute: AppTasksCronManageRoute,
|
||||||
|
AppTasksCronDetailIdRoute: AppTasksCronDetailIdRoute,
|
||||||
|
AppTasksCronEditIdRoute: AppTasksCronEditIdRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppTasksCronRouteRouteWithChildren =
|
||||||
|
AppTasksCronRouteRoute._addFileChildren(AppTasksCronRouteRouteChildren)
|
||||||
|
|
||||||
interface AppTasksRouteRouteChildren {
|
interface AppTasksRouteRouteChildren {
|
||||||
|
AppTasksCronRouteRoute: typeof AppTasksCronRouteRouteWithChildren
|
||||||
AppTasksManageRoute: typeof AppTasksManageRoute
|
AppTasksManageRoute: typeof AppTasksManageRoute
|
||||||
AppTasksDetailIdRoute: typeof AppTasksDetailIdRoute
|
AppTasksDetailIdRoute: typeof AppTasksDetailIdRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppTasksRouteRouteChildren: AppTasksRouteRouteChildren = {
|
const AppTasksRouteRouteChildren: AppTasksRouteRouteChildren = {
|
||||||
|
AppTasksCronRouteRoute: AppTasksCronRouteRouteWithChildren,
|
||||||
AppTasksManageRoute: AppTasksManageRoute,
|
AppTasksManageRoute: AppTasksManageRoute,
|
||||||
AppTasksDetailIdRoute: AppTasksDetailIdRoute,
|
AppTasksDetailIdRoute: AppTasksDetailIdRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import { useMutation } from '@apollo/client';
|
||||||
|
import {
|
||||||
|
createFileRoute,
|
||||||
|
useCanGoBack,
|
||||||
|
useNavigate,
|
||||||
|
useRouter,
|
||||||
|
} from '@tanstack/react-router';
|
||||||
|
import { type } from 'arktype';
|
||||||
|
import { Loader2, Save } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -6,6 +16,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import { ContainerHeader } from '@/components/ui/container-header';
|
||||||
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';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -23,7 +34,9 @@ import {
|
|||||||
INSERT_CREDENTIAL_3RD,
|
INSERT_CREDENTIAL_3RD,
|
||||||
} from '@/domains/recorder/schema/credential3rd';
|
} from '@/domains/recorder/schema/credential3rd';
|
||||||
import { useInject } from '@/infra/di/inject';
|
import { useInject } from '@/infra/di/inject';
|
||||||
|
import { compatFormDefaultValues } from '@/infra/forms/compat';
|
||||||
import {
|
import {
|
||||||
|
type Credential3rdInsertInput,
|
||||||
Credential3rdTypeEnum,
|
Credential3rdTypeEnum,
|
||||||
type InsertCredential3rdMutation,
|
type InsertCredential3rdMutation,
|
||||||
type InsertCredential3rdMutationVariables,
|
type InsertCredential3rdMutationVariables,
|
||||||
@@ -34,16 +47,6 @@ import {
|
|||||||
CreateCompleteActionSchema,
|
CreateCompleteActionSchema,
|
||||||
} from '@/infra/routes/nav';
|
} from '@/infra/routes/nav';
|
||||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
import { useMutation } from '@apollo/client';
|
|
||||||
import {
|
|
||||||
createFileRoute,
|
|
||||||
useCanGoBack,
|
|
||||||
useNavigate,
|
|
||||||
useRouter,
|
|
||||||
} from '@tanstack/react-router';
|
|
||||||
import { type } from 'arktype';
|
|
||||||
import { Loader2, Save } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
const RouteSearchSchema = type({
|
const RouteSearchSchema = type({
|
||||||
completeAction: CreateCompleteActionSchema.optional(),
|
completeAction: CreateCompleteActionSchema.optional(),
|
||||||
@@ -97,21 +100,24 @@ function CredentialCreateRouteComponent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useAppForm({
|
const form = useAppForm({
|
||||||
defaultValues: {
|
defaultValues: compatFormDefaultValues<
|
||||||
|
Credential3rdInsertInput,
|
||||||
|
'credentialType' | 'username' | 'password' | 'userAgent'
|
||||||
|
>({
|
||||||
credentialType: Credential3rdTypeEnum.Mikan,
|
credentialType: Credential3rdTypeEnum.Mikan,
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
userAgent: '',
|
userAgent: '',
|
||||||
},
|
}),
|
||||||
validators: {
|
validators: {
|
||||||
onChangeAsync: Credential3rdInsertSchema,
|
onChangeAsync: Credential3rdInsertSchema,
|
||||||
onChangeAsyncDebounceMs: 300,
|
onChangeAsyncDebounceMs: 300,
|
||||||
onSubmit: Credential3rdInsertSchema,
|
onSubmit: Credential3rdInsertSchema,
|
||||||
},
|
},
|
||||||
onSubmit: async (form) => {
|
onSubmit: async (submittedForm) => {
|
||||||
const value = {
|
const value = {
|
||||||
...form.value,
|
...submittedForm.value,
|
||||||
userAgent: form.value.userAgent || platformService.userAgent,
|
userAgent: submittedForm.value.userAgent || platformService.userAgent,
|
||||||
};
|
};
|
||||||
await insertCredential3rd({
|
await insertCredential3rd({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -123,14 +129,11 @@ function CredentialCreateRouteComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-2xl py-6">
|
<div className="container mx-auto max-w-2xl py-6">
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<ContainerHeader
|
||||||
<div>
|
title="Create third-party credential"
|
||||||
<h1 className="font-bold text-2xl">Create third-party credential</h1>
|
description="Add new third-party login credential"
|
||||||
<p className="mt-1 text-muted-foreground">
|
defaultBackTo="/credential3rd/manage"
|
||||||
Add new third-party login credential
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -185,7 +188,7 @@ function CredentialCreateRouteComponent() {
|
|||||||
<Input
|
<Input
|
||||||
id={field.name}
|
id={field.name}
|
||||||
name={field.name}
|
name={field.name}
|
||||||
value={field.state.value}
|
value={field.state.value ?? ''}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
placeholder="Please enter username"
|
placeholder="Please enter username"
|
||||||
@@ -209,7 +212,7 @@ function CredentialCreateRouteComponent() {
|
|||||||
id={field.name}
|
id={field.name}
|
||||||
name={field.name}
|
name={field.name}
|
||||||
type="password"
|
type="password"
|
||||||
value={field.state.value}
|
value={field.state.value ?? ''}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
placeholder="Please enter password"
|
placeholder="Please enter password"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { DetailCardSkeleton } from '@/components/detail-card-skeleton';
|
import { useQuery } from '@apollo/client';
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { CheckIcon, Edit, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
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 {
|
||||||
@@ -8,24 +11,18 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
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';
|
||||||
import { QueryErrorView } from '@/components/ui/query-error-view';
|
import { QueryErrorView } from '@/components/ui/query-error-view';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { GET_CREDENTIAL_3RD_DETAIL } from '@/domains/recorder/schema/credential3rd';
|
import { GET_CREDENTIAL_3RD_DETAIL } from '@/domains/recorder/schema/credential3rd';
|
||||||
|
import { useInject } from '@/infra/di/inject';
|
||||||
import type { GetCredential3rdDetailQuery } from '@/infra/graphql/gql/graphql';
|
import type { GetCredential3rdDetailQuery } from '@/infra/graphql/gql/graphql';
|
||||||
|
import { IntlService } from '@/infra/intl/intl.service';
|
||||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
import { useQuery } from '@apollo/client';
|
|
||||||
import {
|
|
||||||
createFileRoute,
|
|
||||||
useCanGoBack,
|
|
||||||
useNavigate,
|
|
||||||
useRouter,
|
|
||||||
} from '@tanstack/react-router';
|
|
||||||
import { format } from 'date-fns/format';
|
|
||||||
import { ArrowLeft, CheckIcon, Edit, Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Credential3rdCheckAvailableViewDialogContent } from './-check-available';
|
import { Credential3rdCheckAvailableViewDialogContent } from './-check-available';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/credential3rd/detail/$id')({
|
export const Route = createFileRoute('/_app/credential3rd/detail/$id')({
|
||||||
@@ -38,26 +35,15 @@ export const Route = createFileRoute('/_app/credential3rd/detail/$id')({
|
|||||||
function Credential3rdDetailRouteComponent() {
|
function Credential3rdDetailRouteComponent() {
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const router = useRouter();
|
const intlService = useInject(IntlService);
|
||||||
const canGoBack = useCanGoBack();
|
|
||||||
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
if (canGoBack) {
|
|
||||||
router.history.back();
|
|
||||||
} else {
|
|
||||||
navigate({
|
|
||||||
to: '/credential3rd/manage',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { loading, error, data } = useQuery<GetCredential3rdDetailQuery>(
|
const { loading, error, data } = useQuery<GetCredential3rdDetailQuery>(
|
||||||
GET_CREDENTIAL_3RD_DETAIL,
|
GET_CREDENTIAL_3RD_DETAIL,
|
||||||
{
|
{
|
||||||
variables: {
|
variables: {
|
||||||
id: Number.parseInt(id),
|
id: Number.parseInt(id, 10),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -91,31 +77,17 @@ function Credential3rdDetailRouteComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl py-6">
|
<div className="container mx-auto max-w-4xl py-6">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<ContainerHeader
|
||||||
<div className="flex items-center gap-4">
|
title="Credential Detail"
|
||||||
<Button
|
description={`View credential #${credential.id}`}
|
||||||
variant="ghost"
|
defaultBackTo="/credential3rd/manage"
|
||||||
size="sm"
|
actions={
|
||||||
onClick={handleBack}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="font-bold text-2xl">Credential detail</h1>
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
View credential #{credential.id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleEnterEditMode}>
|
<Button onClick={handleEnterEditMode}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -207,10 +179,7 @@ function Credential3rdDetailRouteComponent() {
|
|||||||
<Label className="font-medium text-sm">Created at</Label>
|
<Label className="font-medium text-sm">Created at</Label>
|
||||||
<div className="rounded-md bg-muted p-3">
|
<div className="rounded-md bg-muted p-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{format(
|
{intlService.formatDatetimeWithTz(credential.createdAt)}
|
||||||
new Date(credential.createdAt),
|
|
||||||
'yyyy-MM-dd HH:mm:ss'
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,10 +188,7 @@ function Credential3rdDetailRouteComponent() {
|
|||||||
<Label className="font-medium text-sm">Updated at</Label>
|
<Label className="font-medium text-sm">Updated at</Label>
|
||||||
<div className="rounded-md bg-muted p-3">
|
<div className="rounded-md bg-muted p-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{format(
|
{intlService.formatDatetimeWithTz(credential.updatedAt)}
|
||||||
new Date(credential.updatedAt),
|
|
||||||
'yyyy-MM-dd HH:mm:ss'
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { DetailCardSkeleton } from '@/components/detail-card-skeleton';
|
import { useMutation, useQuery } from '@apollo/client';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { Eye, EyeOff, Save } from 'lucide-react';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
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 {
|
||||||
@@ -8,6 +12,8 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
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';
|
||||||
@@ -31,23 +37,15 @@ import {
|
|||||||
apolloErrorToMessage,
|
apolloErrorToMessage,
|
||||||
getApolloQueryError,
|
getApolloQueryError,
|
||||||
} from '@/infra/errors/apollo';
|
} from '@/infra/errors/apollo';
|
||||||
|
import { compatFormDefaultValues } from '@/infra/forms/compat';
|
||||||
import type {
|
import type {
|
||||||
Credential3rdTypeEnum,
|
Credential3rdTypeEnum,
|
||||||
|
Credential3rdUpdateInput,
|
||||||
GetCredential3rdDetailQuery,
|
GetCredential3rdDetailQuery,
|
||||||
UpdateCredential3rdMutation,
|
UpdateCredential3rdMutation,
|
||||||
UpdateCredential3rdMutationVariables,
|
UpdateCredential3rdMutationVariables,
|
||||||
} from '@/infra/graphql/gql/graphql';
|
} from '@/infra/graphql/gql/graphql';
|
||||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
import { useMutation, useQuery } from '@apollo/client';
|
|
||||||
import {
|
|
||||||
createFileRoute,
|
|
||||||
useCanGoBack,
|
|
||||||
useNavigate,
|
|
||||||
useRouter,
|
|
||||||
} from '@tanstack/react-router';
|
|
||||||
import { ArrowLeft, Eye, EyeOff, Save, X } from 'lucide-react';
|
|
||||||
import { useCallback, useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/credential3rd/edit/$id')({
|
export const Route = createFileRoute('/_app/credential3rd/edit/$id')({
|
||||||
component: Credential3rdEditRouteComponent,
|
component: Credential3rdEditRouteComponent,
|
||||||
@@ -63,23 +61,10 @@ function FormView({
|
|||||||
credential: Credential3rdDetailDto;
|
credential: Credential3rdDetailDto;
|
||||||
onCompleted: VoidFunction;
|
onCompleted: VoidFunction;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const togglePasswordVisibility = () => {
|
const togglePasswordVisibility = () => {
|
||||||
setShowPassword((prev) => !prev);
|
setShowPassword((prev) => !prev);
|
||||||
};
|
};
|
||||||
const router = useRouter();
|
|
||||||
const canGoBack = useCanGoBack();
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
if (canGoBack) {
|
|
||||||
router.history.back();
|
|
||||||
} else {
|
|
||||||
navigate({
|
|
||||||
to: '/credential3rd/manage',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const [updateCredential, { loading: updating }] = useMutation<
|
const [updateCredential, { loading: updating }] = useMutation<
|
||||||
UpdateCredential3rdMutation,
|
UpdateCredential3rdMutation,
|
||||||
@@ -94,18 +79,21 @@ function FormView({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useAppForm({
|
const form = useAppForm({
|
||||||
defaultValues: {
|
defaultValues: compatFormDefaultValues<
|
||||||
|
Credential3rdUpdateInput,
|
||||||
|
'credentialType' | 'username' | 'password' | 'userAgent'
|
||||||
|
>({
|
||||||
credentialType: credential.credentialType,
|
credentialType: credential.credentialType,
|
||||||
username: credential.username,
|
username: credential.username ?? '',
|
||||||
password: credential.password,
|
password: credential.password ?? '',
|
||||||
userAgent: credential.userAgent,
|
userAgent: credential.userAgent ?? '',
|
||||||
},
|
}),
|
||||||
validators: {
|
validators: {
|
||||||
onBlur: Credential3rdUpdateSchema,
|
onBlur: Credential3rdUpdateSchema,
|
||||||
onSubmit: Credential3rdUpdateSchema,
|
onSubmit: Credential3rdUpdateSchema,
|
||||||
},
|
},
|
||||||
onSubmit: (form) => {
|
onSubmit: (submittedForm) => {
|
||||||
const value = form.value;
|
const value = submittedForm.value;
|
||||||
updateCredential({
|
updateCredential({
|
||||||
variables: {
|
variables: {
|
||||||
data: value,
|
data: value,
|
||||||
@@ -121,35 +109,17 @@ function FormView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl py-6">
|
<div className="container mx-auto max-w-4xl py-6">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<ContainerHeader
|
||||||
<div className="flex items-center gap-4">
|
title="Credential Edit"
|
||||||
<Button
|
description={`Edit credential #${credential.id}`}
|
||||||
variant="ghost"
|
defaultBackTo={`/credential3rd/detail/${credential.id}`}
|
||||||
size="sm"
|
actions={
|
||||||
onClick={handleBack}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="font-bold text-2xl">Credential edit</h1>
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
Edit credential #{credential.id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={handleBack} disabled={updating}>
|
|
||||||
<X className="mr-2 h-4 w-4" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => form.handleSubmit()} disabled={updating}>
|
<Button onClick={() => form.handleSubmit()} disabled={updating}>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{updating ? 'Saving...' : 'Save'}
|
{updating ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -273,7 +243,7 @@ function Credential3rdEditRouteComponent() {
|
|||||||
const { loading, error, data, refetch } =
|
const { loading, error, data, refetch } =
|
||||||
useQuery<GetCredential3rdDetailQuery>(GET_CREDENTIAL_3RD_DETAIL, {
|
useQuery<GetCredential3rdDetailQuery>(GET_CREDENTIAL_3RD_DETAIL, {
|
||||||
variables: {
|
variables: {
|
||||||
id: Number.parseInt(id),
|
id: Number.parseInt(id, 10),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -281,10 +251,10 @@ function Credential3rdEditRouteComponent() {
|
|||||||
|
|
||||||
const onCompleted = useCallback(async () => {
|
const onCompleted = useCallback(async () => {
|
||||||
const refetchResult = await refetch();
|
const refetchResult = await refetch();
|
||||||
const error = getApolloQueryError(refetchResult);
|
const _error = getApolloQueryError(refetchResult);
|
||||||
if (error) {
|
if (_error) {
|
||||||
toast.error('Update credential failed', {
|
toast.error('Update credential failed', {
|
||||||
description: apolloErrorToMessage(error),
|
description: apolloErrorToMessage(_error),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.success('Update credential successfully');
|
toast.success('Update credential successfully');
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
|
import { useMutation, useQuery } from '@apollo/client';
|
||||||
|
import { Dialog } from '@radix-ui/react-dialog';
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
type PaginationState,
|
||||||
|
type Row,
|
||||||
|
type SortingState,
|
||||||
|
useReactTable,
|
||||||
|
type VisibilityState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { Eye, EyeOff, Plus } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
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 { ContainerHeader } from '@/components/ui/container-header';
|
||||||
import { DataTablePagination } from '@/components/ui/data-table-pagination';
|
import { DataTablePagination } from '@/components/ui/data-table-pagination';
|
||||||
import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
|
import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
|
||||||
import { DialogTrigger } from '@/components/ui/dialog';
|
import { DialogTrigger } from '@/components/ui/dialog';
|
||||||
@@ -20,33 +38,17 @@ import {
|
|||||||
DELETE_CREDENTIAL_3RD,
|
DELETE_CREDENTIAL_3RD,
|
||||||
GET_CREDENTIAL_3RD,
|
GET_CREDENTIAL_3RD,
|
||||||
} from '@/domains/recorder/schema/credential3rd';
|
} from '@/domains/recorder/schema/credential3rd';
|
||||||
|
import { useInject } from '@/infra/di/inject';
|
||||||
import {
|
import {
|
||||||
apolloErrorToMessage,
|
apolloErrorToMessage,
|
||||||
getApolloQueryError,
|
getApolloQueryError,
|
||||||
} from '@/infra/errors/apollo';
|
} from '@/infra/errors/apollo';
|
||||||
import type { GetCredential3rdQuery } from '@/infra/graphql/gql/graphql';
|
import type { GetCredential3rdQuery } from '@/infra/graphql/gql/graphql';
|
||||||
|
import { IntlService } from '@/infra/intl/intl.service';
|
||||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
|
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
|
||||||
import { useEvent } from '@/presentation/hooks/use-event';
|
import { useEvent } from '@/presentation/hooks/use-event';
|
||||||
import { cn } from '@/presentation/utils';
|
import { cn } from '@/presentation/utils';
|
||||||
import { useMutation, useQuery } from '@apollo/client';
|
|
||||||
import { Dialog } from '@radix-ui/react-dialog';
|
|
||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
||||||
import {
|
|
||||||
type ColumnDef,
|
|
||||||
type PaginationState,
|
|
||||||
type Row,
|
|
||||||
type SortingState,
|
|
||||||
type VisibilityState,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { Eye, EyeOff, Plus } from 'lucide-react';
|
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Credential3rdCheckAvailableViewDialogContent } from './-check-available';
|
import { Credential3rdCheckAvailableViewDialogContent } from './-check-available';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/credential3rd/manage')({
|
export const Route = createFileRoute('/_app/credential3rd/manage')({
|
||||||
@@ -58,6 +60,7 @@ export const Route = createFileRoute('/_app/credential3rd/manage')({
|
|||||||
|
|
||||||
function CredentialManageRouteComponent() {
|
function CredentialManageRouteComponent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const intlService = useInject(IntlService);
|
||||||
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||||
createdAt: false,
|
createdAt: false,
|
||||||
@@ -93,18 +96,18 @@ function CredentialManageRouteComponent() {
|
|||||||
const [deleteCredential] = useMutation(DELETE_CREDENTIAL_3RD, {
|
const [deleteCredential] = useMutation(DELETE_CREDENTIAL_3RD, {
|
||||||
onCompleted: async () => {
|
onCompleted: async () => {
|
||||||
const refetchResult = await refetch();
|
const refetchResult = await refetch();
|
||||||
const error = getApolloQueryError(refetchResult);
|
const e = getApolloQueryError(refetchResult);
|
||||||
if (error) {
|
if (e) {
|
||||||
toast.error('Failed to delete credential', {
|
toast.error('Failed to delete credential', {
|
||||||
description: apolloErrorToMessage(error),
|
description: apolloErrorToMessage(e),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success('Credential deleted');
|
toast.success('Credential deleted');
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (e) => {
|
||||||
toast.error('Failed to delete credential', {
|
toast.error('Failed to delete credential', {
|
||||||
description: error.message,
|
description: e.message,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -211,7 +214,7 @@ function CredentialManageRouteComponent() {
|
|||||||
const createdAt = row.original.createdAt;
|
const createdAt = row.original.createdAt;
|
||||||
return (
|
return (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{format(new Date(createdAt), 'yyyy-MM-dd HH:mm:ss')}
|
{intlService.formatDatetimeWithTz(createdAt)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -223,7 +226,7 @@ function CredentialManageRouteComponent() {
|
|||||||
const updatedAt = row.original.updatedAt;
|
const updatedAt = row.original.updatedAt;
|
||||||
return (
|
return (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{format(new Date(updatedAt), 'yyyy-MM-dd HH:mm:ss')}
|
{intlService.formatDatetimeWithTz(updatedAt)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -265,7 +268,13 @@ function CredentialManageRouteComponent() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
return cs;
|
return cs;
|
||||||
}, [handleDeleteRecord, navigate, showPasswords, togglePasswordVisibility]);
|
}, [
|
||||||
|
handleDeleteRecord,
|
||||||
|
navigate,
|
||||||
|
showPasswords,
|
||||||
|
togglePasswordVisibility,
|
||||||
|
intlService.formatDatetimeWithTz,
|
||||||
|
]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: useMemo(() => credentials?.nodes ?? [], [credentials]),
|
data: useMemo(() => credentials?.nodes ?? [], [credentials]),
|
||||||
@@ -297,18 +306,16 @@ function CredentialManageRouteComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-4 rounded-md">
|
<div className="container mx-auto space-y-4 rounded-md">
|
||||||
<div className="flex items-center justify-between pt-4">
|
<ContainerHeader
|
||||||
<div>
|
title="Credential 3rd Management"
|
||||||
<h1 className="font-bold text-2xl">Credential 3rd Management</h1>
|
description="Manage your third-party platform login credentials"
|
||||||
<p className="text-muted-foreground">
|
actions={
|
||||||
Manage your third-party platform login credentials
|
<Button onClick={() => navigate({ to: '/credential3rd/create' })}>
|
||||||
</p>
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
</div>
|
Add Credential
|
||||||
<Button onClick={() => navigate({ to: '/credential3rd/create' })}>
|
</Button>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
}
|
||||||
Add Credential
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center py-2">
|
<div className="flex items-center py-2">
|
||||||
<DataTableViewOptions table={table} />
|
<DataTableViewOptions table={table} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { useMutation } from '@apollo/client';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { CalendarIcon } from 'lucide-react';
|
||||||
|
import { memo, useCallback, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Cron } from '@/components/ui/cron';
|
||||||
|
import { CronMode } from '@/components/ui/cron/types';
|
||||||
|
import {
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { INSERT_CRON } from '@/domains/recorder/schema/cron';
|
||||||
|
import { useInject } from '@/infra/di/inject';
|
||||||
|
import {
|
||||||
|
type InsertCronMutation,
|
||||||
|
type InsertCronMutationVariables,
|
||||||
|
SubscriberTaskTypeEnum,
|
||||||
|
} from '@/infra/graphql/gql/graphql';
|
||||||
|
import { IntlService } from '@/infra/intl/intl.service';
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CRON_TABS = [
|
||||||
|
{
|
||||||
|
tab: SubscriberTaskTypeEnum.SyncOneSubscriptionSources,
|
||||||
|
label: 'Sync sources',
|
||||||
|
description: 'Syncs subscription sources',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tab: SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsIncremental,
|
||||||
|
label: 'Feeds incremental',
|
||||||
|
description: 'Syncs incremental subscription feeds',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tab: SubscriberTaskTypeEnum.SyncOneSubscriptionFeedsFull,
|
||||||
|
label: 'Feeds full',
|
||||||
|
description: 'Syncs all subscription feeds',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export type SubscriptionCronCreationViewCompletePayload = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SubscriptionCronCreationViewProps {
|
||||||
|
subscriptionId: number;
|
||||||
|
onComplete: (payload: SubscriptionCronCreationViewCompletePayload) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionCronFormProps {
|
||||||
|
tab: (typeof CRON_TABS)[number];
|
||||||
|
timezone: string;
|
||||||
|
onComplete: (payload: SubscriptionCronFormPayload) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionCronFormPayload {
|
||||||
|
cronExpr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubscriptionCronForm = memo(
|
||||||
|
({ tab, timezone, onComplete }: SubscriptionCronFormProps) => {
|
||||||
|
const [cronExpr, setCronExpr] = useState<string>('');
|
||||||
|
return (
|
||||||
|
<Card className="overflow-y-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{tab.label}</CardTitle>
|
||||||
|
<CardDescription>{tab.description}</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button variant="default" onClick={() => onComplete({ cronExpr })}>
|
||||||
|
<CalendarIcon className="size-4" />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Separator />
|
||||||
|
<Cron
|
||||||
|
mode={CronMode.Both}
|
||||||
|
withCard={false}
|
||||||
|
showPresets={false}
|
||||||
|
presets={SUBSCRIPTION_TASK_CRON_PRESETS}
|
||||||
|
timezone={timezone}
|
||||||
|
onChange={setCronExpr}
|
||||||
|
value={cronExpr}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SubscriptionCronCreationView = memo(
|
||||||
|
({ subscriptionId, onComplete }: SubscriptionCronCreationViewProps) => {
|
||||||
|
const intlService = useInject(IntlService);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Tabs
|
||||||
|
defaultValue={CRON_TABS[0].tab}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
|
<div className="flex w-full shrink-0 overflow-x-auto">
|
||||||
|
<TabsList className="flex items-center justify-center whitespace-nowrap">
|
||||||
|
{CRON_TABS.map((tab) => (
|
||||||
|
<TabsTrigger key={tab.tab} value={tab.tab} className="w-fit px-4">
|
||||||
|
{tab.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
{CRON_TABS.map((tab) => (
|
||||||
|
<TabsContent
|
||||||
|
key={tab.tab}
|
||||||
|
value={tab.tab}
|
||||||
|
className="flex-1 space-y-2"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<SubscriptionCronForm
|
||||||
|
tab={tab}
|
||||||
|
onComplete={(payload) => {
|
||||||
|
insertCron({
|
||||||
|
variables: {
|
||||||
|
data: {
|
||||||
|
cronExpr: payload.cronExpr,
|
||||||
|
cronTimezone: intlService.timezone,
|
||||||
|
subscriberTaskCron: {
|
||||||
|
subscriptionId,
|
||||||
|
taskType: tab.tab,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
timezone={intlService.timezone}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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 xl:max-w-2xl"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Cron</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a cron to execute the subscription.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<SubscriptionCronCreationView
|
||||||
|
subscriptionId={subscriptionId}
|
||||||
|
onComplete={handleCreationComplete}
|
||||||
|
/>
|
||||||
|
</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,17 +119,20 @@ export const SubscriptionSyncView = memo(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface SubscriptionSyncDialogContentProps {
|
export interface SubscriptionTaskCreationDialogContentProps {
|
||||||
id: number;
|
subscriptionId: number;
|
||||||
onCancel?: VoidFunction;
|
onCancel?: VoidFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
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: {
|
||||||
@@ -143,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,3 +1,7 @@
|
|||||||
|
import { useMutation } from '@apollo/client';
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { Loader2, Save } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -6,6 +10,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import { ContainerHeader } from '@/components/ui/container-header';
|
||||||
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';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -26,6 +31,7 @@ import {
|
|||||||
} from '@/domains/recorder/schema/subscriptions';
|
} from '@/domains/recorder/schema/subscriptions';
|
||||||
import { SubscriptionService } from '@/domains/recorder/services/subscription.service';
|
import { SubscriptionService } from '@/domains/recorder/services/subscription.service';
|
||||||
import { useInject } from '@/infra/di/inject';
|
import { useInject } from '@/infra/di/inject';
|
||||||
|
import { compatFormDefaultValues } from '@/infra/forms/compat';
|
||||||
import {
|
import {
|
||||||
Credential3rdTypeEnum,
|
Credential3rdTypeEnum,
|
||||||
type InsertSubscriptionMutation,
|
type InsertSubscriptionMutation,
|
||||||
@@ -33,11 +39,6 @@ import {
|
|||||||
SubscriptionCategoryEnum,
|
SubscriptionCategoryEnum,
|
||||||
} from '@/infra/graphql/gql/graphql';
|
} from '@/infra/graphql/gql/graphql';
|
||||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
import { useMutation } from '@apollo/client';
|
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
|
||||||
import { Loader2, Save } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Credential3rdSelectContent } from './-credential3rd-select';
|
import { Credential3rdSelectContent } from './-credential3rd-select';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/subscriptions/create')({
|
export const Route = createFileRoute('/_app/subscriptions/create')({
|
||||||
@@ -70,22 +71,24 @@ function SubscriptionCreateRouteComponent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useAppForm({
|
const form = useAppForm({
|
||||||
defaultValues: {
|
defaultValues: compatFormDefaultValues<SubscriptionForm>({
|
||||||
displayName: '',
|
displayName: '',
|
||||||
category: undefined,
|
category: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sourceUrl: '',
|
sourceUrl: '',
|
||||||
credentialId: '',
|
credentialId: Number.NaN,
|
||||||
year: undefined,
|
year: Number.NaN,
|
||||||
seasonStr: '',
|
seasonStr: '',
|
||||||
} as unknown as SubscriptionForm,
|
}),
|
||||||
validators: {
|
validators: {
|
||||||
onChangeAsync: SubscriptionFormSchema,
|
onChangeAsync: SubscriptionFormSchema,
|
||||||
onChangeAsyncDebounceMs: 300,
|
onChangeAsyncDebounceMs: 300,
|
||||||
onSubmit: SubscriptionFormSchema,
|
onSubmit: SubscriptionFormSchema,
|
||||||
},
|
},
|
||||||
onSubmit: async (form) => {
|
onSubmit: async (submittedForm) => {
|
||||||
const input = subscriptionService.transformInsertFormToInput(form.value);
|
const input = subscriptionService.transformInsertFormToInput(
|
||||||
|
submittedForm.value
|
||||||
|
);
|
||||||
await insertSubscription({
|
await insertSubscription({
|
||||||
variables: {
|
variables: {
|
||||||
data: input,
|
data: input,
|
||||||
@@ -96,14 +99,11 @@ function SubscriptionCreateRouteComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-2xl py-6">
|
<div className="container mx-auto max-w-2xl py-6">
|
||||||
<div className="mb-6 flex items-center gap-4">
|
<ContainerHeader
|
||||||
<div>
|
title="Create Bangumi Subscription"
|
||||||
<h1 className="font-bold text-2xl">Create Bangumi Subscription</h1>
|
description="Add a new bangumi subscription source"
|
||||||
<p className="mt-1 text-muted-foreground">
|
defaultBackTo="/subscriptions/manage"
|
||||||
Add a new bangumi subscription source
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -121,30 +121,6 @@ function SubscriptionCreateRouteComponent() {
|
|||||||
}}
|
}}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<form.Field name="displayName">
|
|
||||||
{(field) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={field.name}>Display Name *</Label>
|
|
||||||
<Input
|
|
||||||
id={field.name}
|
|
||||||
name={field.name}
|
|
||||||
value={field.state.value}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
placeholder="Please enter display name"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
{field.state.meta.errors && (
|
|
||||||
<FormFieldErrors
|
|
||||||
errors={field.state.meta.errors}
|
|
||||||
isDirty={field.state.meta.isDirty}
|
|
||||||
submissionAttempts={form.state.submissionAttempts}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
<form.Field name="category">
|
<form.Field name="category">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -194,7 +170,7 @@ function SubscriptionCreateRouteComponent() {
|
|||||||
<Select
|
<Select
|
||||||
value={field.state.value.toString()}
|
value={field.state.value.toString()}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
field.handleChange(Number.parseInt(value))
|
field.handleChange(Number.parseInt(value, 10))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -229,7 +205,7 @@ function SubscriptionCreateRouteComponent() {
|
|||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.handleChange(
|
field.handleChange(
|
||||||
Number.parseInt(e.target.value)
|
Number.parseInt(e.target.value, 10)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder={`Please enter full year (e.g. ${new Date().getFullYear()})`}
|
placeholder={`Please enter full year (e.g. ${new Date().getFullYear()})`}
|
||||||
@@ -317,6 +293,29 @@ function SubscriptionCreateRouteComponent() {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
|
<form.Field name="displayName">
|
||||||
|
{(field) => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={field.name}>Display Name *</Label>
|
||||||
|
<Input
|
||||||
|
id={field.name}
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="Please enter display name"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{field.state.meta.errors && (
|
||||||
|
<FormFieldErrors
|
||||||
|
errors={field.state.meta.errors}
|
||||||
|
isDirty={field.state.meta.isDirty}
|
||||||
|
submissionAttempts={form.state.submissionAttempts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
<form.Field name="enabled">
|
<form.Field name="enabled">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
import { DetailCardSkeleton } from '@/components/detail-card-skeleton';
|
import { useMutation, useQuery } from '@apollo/client';
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
Edit,
|
||||||
|
ExternalLink,
|
||||||
|
ListIcon,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCcwIcon,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
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 {
|
||||||
@@ -8,14 +21,19 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
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';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { ProLink } from '@/components/ui/pro-link';
|
||||||
import { QueryErrorView } from '@/components/ui/query-error-view';
|
import { QueryErrorView } from '@/components/ui/query-error-view';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { UPDATE_CRONS } from '@/domains/recorder/schema/cron';
|
||||||
import { DELETE_FEED, INSERT_FEED } from '@/domains/recorder/schema/feeds';
|
import { DELETE_FEED, INSERT_FEED } from '@/domains/recorder/schema/feeds';
|
||||||
import { GET_SUBSCRIPTION_DETAIL } from '@/domains/recorder/schema/subscriptions';
|
import { GET_SUBSCRIPTION_DETAIL } from '@/domains/recorder/schema/subscriptions';
|
||||||
|
import { DELETE_TASKS } from '@/domains/recorder/schema/tasks';
|
||||||
import { SubscriptionService } from '@/domains/recorder/services/subscription.service';
|
import { SubscriptionService } from '@/domains/recorder/services/subscription.service';
|
||||||
import { useInject } from '@/infra/di/inject';
|
import { useInject } from '@/infra/di/inject';
|
||||||
import {
|
import {
|
||||||
@@ -25,34 +43,22 @@ import {
|
|||||||
import {
|
import {
|
||||||
type DeleteFeedMutation,
|
type DeleteFeedMutation,
|
||||||
type DeleteFeedMutationVariables,
|
type DeleteFeedMutationVariables,
|
||||||
|
type DeleteTasksMutation,
|
||||||
|
type DeleteTasksMutationVariables,
|
||||||
FeedSourceEnum,
|
FeedSourceEnum,
|
||||||
FeedTypeEnum,
|
FeedTypeEnum,
|
||||||
type GetSubscriptionDetailQuery,
|
type GetSubscriptionDetailQuery,
|
||||||
|
type GetSubscriptionDetailQueryVariables,
|
||||||
type InsertFeedMutation,
|
type InsertFeedMutation,
|
||||||
type InsertFeedMutationVariables,
|
type InsertFeedMutationVariables,
|
||||||
SubscriptionCategoryEnum,
|
SubscriptionCategoryEnum,
|
||||||
|
type UpdateCronsMutation,
|
||||||
|
type UpdateCronsMutationVariables,
|
||||||
} from '@/infra/graphql/gql/graphql';
|
} from '@/infra/graphql/gql/graphql';
|
||||||
import { useMutation, useQuery } from '@apollo/client';
|
import { IntlService } from '@/infra/intl/intl.service';
|
||||||
import {
|
|
||||||
createFileRoute,
|
|
||||||
useCanGoBack,
|
|
||||||
useNavigate,
|
|
||||||
useRouter,
|
|
||||||
} from '@tanstack/react-router';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Edit,
|
|
||||||
ExternalLink,
|
|
||||||
ListIcon,
|
|
||||||
PlusIcon,
|
|
||||||
RefreshCcwIcon,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
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,
|
||||||
@@ -61,19 +67,8 @@ export const Route = createFileRoute('/_app/subscriptions/detail/$id')({
|
|||||||
function SubscriptionDetailRouteComponent() {
|
function SubscriptionDetailRouteComponent() {
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const router = useRouter();
|
|
||||||
const canGoBack = useCanGoBack();
|
|
||||||
const subscriptionService = useInject(SubscriptionService);
|
const subscriptionService = useInject(SubscriptionService);
|
||||||
|
const intlService = useInject(IntlService);
|
||||||
const handleBack = () => {
|
|
||||||
if (canGoBack) {
|
|
||||||
router.history.back();
|
|
||||||
} else {
|
|
||||||
navigate({
|
|
||||||
to: '/subscriptions/manage',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReload = async () => {
|
const handleReload = async () => {
|
||||||
const result = await refetch();
|
const result = await refetch();
|
||||||
@@ -85,12 +80,23 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data, loading, error, refetch } =
|
const {
|
||||||
useQuery<GetSubscriptionDetailQuery>(GET_SUBSCRIPTION_DETAIL, {
|
data,
|
||||||
|
loading,
|
||||||
|
error: subscriptionError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<GetSubscriptionDetailQuery, GetSubscriptionDetailQueryVariables>(
|
||||||
|
GET_SUBSCRIPTION_DETAIL,
|
||||||
|
{
|
||||||
variables: {
|
variables: {
|
||||||
id: Number.parseInt(id),
|
filter: {
|
||||||
|
id: {
|
||||||
|
eq: Number.parseInt(id, 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const handleEnterEditMode = () => {
|
const handleEnterEditMode = () => {
|
||||||
navigate({
|
navigate({
|
||||||
@@ -145,6 +151,50 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [deleteTask] = useMutation<
|
||||||
|
DeleteTasksMutation,
|
||||||
|
DeleteTasksMutationVariables
|
||||||
|
>(DELETE_TASKS, {
|
||||||
|
onCompleted: async () => {
|
||||||
|
const result = await refetch();
|
||||||
|
const error = getApolloQueryError(result);
|
||||||
|
if (error) {
|
||||||
|
toast.error('Failed to delete task', {
|
||||||
|
description: apolloErrorToMessage(error),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Task deleted');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Failed to delete task', {
|
||||||
|
description: apolloErrorToMessage(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateCron] = useMutation<
|
||||||
|
UpdateCronsMutation,
|
||||||
|
UpdateCronsMutationVariables
|
||||||
|
>(UPDATE_CRONS, {
|
||||||
|
onCompleted: async () => {
|
||||||
|
const result = await refetch();
|
||||||
|
const error = getApolloQueryError(result);
|
||||||
|
if (error) {
|
||||||
|
toast.error('Failed to update cron', {
|
||||||
|
description: apolloErrorToMessage(error),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Cron updated');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error('Failed to update cron', {
|
||||||
|
description: apolloErrorToMessage(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const subscription = data?.subscriptions?.nodes?.[0];
|
const subscription = data?.subscriptions?.nodes?.[0];
|
||||||
|
|
||||||
const sourceUrlMeta = useMemo(
|
const sourceUrlMeta = useMemo(
|
||||||
@@ -167,8 +217,8 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
return <DetailCardSkeleton />;
|
return <DetailCardSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (subscriptionError) {
|
||||||
return <QueryErrorView message={error.message} />;
|
return <QueryErrorView message={subscriptionError.message} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
@@ -177,31 +227,16 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl py-6">
|
<div className="container mx-auto max-w-4xl py-6">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<ContainerHeader
|
||||||
<div className="flex items-center gap-4">
|
title="Subscription Detail"
|
||||||
<Button
|
description={`View subscription #${subscription.id}`}
|
||||||
variant="ghost"
|
actions={
|
||||||
size="sm"
|
|
||||||
onClick={handleBack}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="font-bold text-2xl">Subscription detail</h1>
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
View subscription #{subscription.id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleEnterEditMode}>
|
<Button onClick={handleEnterEditMode}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Button>{' '}
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -321,10 +356,7 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
<Label className="font-medium text-sm">Created at</Label>
|
<Label className="font-medium text-sm">Created at</Label>
|
||||||
<div className="rounded-md bg-muted p-3">
|
<div className="rounded-md bg-muted p-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{format(
|
{intlService.formatDatetimeWithTz(subscription.createdAt)}
|
||||||
new Date(subscription.createdAt),
|
|
||||||
'yyyy-MM-dd HH:mm:ss'
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,10 +365,7 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
<Label className="font-medium text-sm">Updated at</Label>
|
<Label className="font-medium text-sm">Updated at</Label>
|
||||||
<div className="rounded-md bg-muted p-3">
|
<div className="rounded-md bg-muted p-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{format(
|
{intlService.formatDatetimeWithTz(subscription.updatedAt)}
|
||||||
new Date(subscription.updatedAt),
|
|
||||||
'yyyy-MM-dd HH:mm:ss'
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -353,7 +382,7 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
insertFeed({
|
insertFeed({
|
||||||
variables: {
|
variables: {
|
||||||
data: {
|
data: {
|
||||||
subscriptionId: Number.parseInt(id),
|
subscriptionId: Number.parseInt(id, 10),
|
||||||
feedType: FeedTypeEnum.Rss,
|
feedType: FeedTypeEnum.Rss,
|
||||||
feedSource: FeedSourceEnum.SubscriptionEpisode,
|
feedSource: FeedSourceEnum.SubscriptionEpisode,
|
||||||
},
|
},
|
||||||
@@ -408,7 +437,7 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
</code>
|
</code>
|
||||||
|
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
{format(new Date(feed.createdAt), 'MM-dd HH:mm')}
|
{intlService.formatDatetimeWithTz(feed.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -421,6 +450,104 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="font-medium text-sm">Associated Crons</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: '/tasks/cron/manage',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListIcon className="h-4 w-4" />
|
||||||
|
More
|
||||||
|
</Button>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
Add Cron
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<SubscriptionCronCreationDialogContent
|
||||||
|
subscriptionId={subscription.id}
|
||||||
|
onCancel={handleReload}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{subscription.cron?.nodes &&
|
||||||
|
subscription.cron.nodes.length > 0 ? (
|
||||||
|
subscription.cron.nodes.map((cron) => (
|
||||||
|
<Card
|
||||||
|
key={cron.id}
|
||||||
|
className="group relative cursor-pointer p-4 transition-colors hover:bg-accent/50"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
to: '/tasks/cron/detail/$id',
|
||||||
|
params: {
|
||||||
|
id: cron.id.toString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="font-medium text-sm capitalize">
|
||||||
|
<span>{cron.cronExpr}</span>
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateCron({
|
||||||
|
variables: {
|
||||||
|
filter: {
|
||||||
|
id: {
|
||||||
|
eq: cron.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
enabled: !cron.enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cron.enabled ? (
|
||||||
|
<Pause className="h-3 w-3 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<code className="break-all rounded bg-muted px-2 py-1 font-mono text-xs">
|
||||||
|
{cron.id}
|
||||||
|
</code>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{cron.status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="col-span-full py-8 text-center text-muted-foreground">
|
||||||
|
No associated crons now
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -436,17 +563,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>
|
||||||
@@ -473,6 +600,25 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
<Label className="font-medium text-sm capitalize">
|
<Label className="font-medium text-sm capitalize">
|
||||||
<span>{prettyTaskType(task.taskType)} Task</span>
|
<span>{prettyTaskType(task.taskType)} Task</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteTask({
|
||||||
|
variables: {
|
||||||
|
filter: {
|
||||||
|
id: {
|
||||||
|
eq: task.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 text-destructive" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<code className="break-all rounded bg-muted px-2 py-1 font-mono text-xs">
|
<code className="break-all rounded bg-muted px-2 py-1 font-mono text-xs">
|
||||||
@@ -546,24 +692,22 @@ function SubscriptionDetailRouteComponent() {
|
|||||||
Updated At
|
Updated At
|
||||||
</Label>
|
</Label>
|
||||||
<div className="font-mono text-sm">
|
<div className="font-mono text-sm">
|
||||||
{format(
|
{intlService.formatDatetimeWithTz(
|
||||||
new Date(bangumi.updatedAt),
|
bangumi.updatedAt
|
||||||
'yyyy-MM-dd'
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{bangumi.homepage && (
|
{bangumi.homepage && (
|
||||||
<div className="mt-3 border-t pt-3">
|
<div className="mt-3 border-t pt-3">
|
||||||
<Button
|
<Button variant="outline" size="sm" asChild>
|
||||||
variant="outline"
|
<ProLink
|
||||||
size="sm"
|
href={bangumi.homepage}
|
||||||
onClick={() =>
|
target="_blank"
|
||||||
window.open(bangumi.homepage!, '_blank')
|
>
|
||||||
}
|
<ExternalLink className="mr-2 h-3 w-3" />
|
||||||
>
|
Homepage
|
||||||
<ExternalLink className="mr-2 h-3 w-3" />
|
</ProLink>
|
||||||
Homepage
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { DetailCardSkeleton } from '@/components/detail-card-skeleton';
|
import { useMutation, useQuery } from '@apollo/client';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
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 {
|
||||||
@@ -8,6 +12,8 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
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';
|
||||||
@@ -35,6 +41,7 @@ import {
|
|||||||
apolloErrorToMessage,
|
apolloErrorToMessage,
|
||||||
getApolloQueryError,
|
getApolloQueryError,
|
||||||
} from '@/infra/errors/apollo';
|
} from '@/infra/errors/apollo';
|
||||||
|
import { compatFormDefaultValues } from '@/infra/forms/compat';
|
||||||
import {
|
import {
|
||||||
Credential3rdTypeEnum,
|
Credential3rdTypeEnum,
|
||||||
type GetSubscriptionDetailQuery,
|
type GetSubscriptionDetailQuery,
|
||||||
@@ -43,11 +50,6 @@ import {
|
|||||||
type UpdateSubscriptionsMutationVariables,
|
type UpdateSubscriptionsMutationVariables,
|
||||||
} from '@/infra/graphql/gql/graphql';
|
} from '@/infra/graphql/gql/graphql';
|
||||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
import { useMutation, useQuery } from '@apollo/client';
|
|
||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
||||||
import { ArrowLeft, Save, X } from 'lucide-react';
|
|
||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Credential3rdSelectContent } from './-credential3rd-select';
|
import { Credential3rdSelectContent } from './-credential3rd-select';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/subscriptions/edit/$id')({
|
export const Route = createFileRoute('/_app/subscriptions/edit/$id')({
|
||||||
@@ -68,16 +70,8 @@ function FormView({
|
|||||||
subscription: SubscriptionDetailDto;
|
subscription: SubscriptionDetailDto;
|
||||||
onCompleted: VoidFunction;
|
onCompleted: VoidFunction;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
|
||||||
const subscriptionService = useInject(SubscriptionService);
|
const subscriptionService = useInject(SubscriptionService);
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
navigate({
|
|
||||||
to: '/subscriptions/detail/$id',
|
|
||||||
params: { id: subscription.id.toString() },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const [updateSubscription, { loading: updating }] = useMutation<
|
const [updateSubscription, { loading: updating }] = useMutation<
|
||||||
UpdateSubscriptionsMutation,
|
UpdateSubscriptionsMutation,
|
||||||
UpdateSubscriptionsMutationVariables
|
UpdateSubscriptionsMutationVariables
|
||||||
@@ -107,7 +101,9 @@ function FormView({
|
|||||||
category: subscription.category,
|
category: subscription.category,
|
||||||
enabled: subscription.enabled,
|
enabled: subscription.enabled,
|
||||||
sourceUrl: subscription.sourceUrl,
|
sourceUrl: subscription.sourceUrl,
|
||||||
credentialId: subscription.credential3rd?.id || '',
|
credentialId: subscription.credential3rd?.id ?? Number.NaN,
|
||||||
|
year: Number.NaN,
|
||||||
|
seasonStr: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -125,14 +121,16 @@ function FormView({
|
|||||||
}, [subscription, sourceUrlMeta]);
|
}, [subscription, sourceUrlMeta]);
|
||||||
|
|
||||||
const form = useAppForm({
|
const form = useAppForm({
|
||||||
defaultValues: defaultValues as unknown as SubscriptionForm,
|
defaultValues: compatFormDefaultValues<SubscriptionForm>(defaultValues),
|
||||||
validators: {
|
validators: {
|
||||||
onChangeAsync: SubscriptionFormSchema,
|
onChangeAsync: SubscriptionFormSchema,
|
||||||
onChangeAsyncDebounceMs: 300,
|
onChangeAsyncDebounceMs: 300,
|
||||||
onSubmit: SubscriptionFormSchema,
|
onSubmit: SubscriptionFormSchema,
|
||||||
},
|
},
|
||||||
onSubmit: async (form) => {
|
onSubmit: async (submittedForm) => {
|
||||||
const input = subscriptionService.transformInsertFormToInput(form.value);
|
const input = subscriptionService.transformInsertFormToInput(
|
||||||
|
submittedForm.value
|
||||||
|
);
|
||||||
|
|
||||||
await updateSubscription({
|
await updateSubscription({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -149,35 +147,17 @@ function FormView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl py-6">
|
<div className="container mx-auto max-w-4xl py-6">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<ContainerHeader
|
||||||
<div className="flex items-center gap-4">
|
title="Subscription Edit"
|
||||||
<Button
|
description={`Edit subscription #${subscription.id}`}
|
||||||
variant="ghost"
|
defaultBackTo={`/subscriptions/detail/${subscription.id}`}
|
||||||
size="sm"
|
actions={
|
||||||
onClick={handleBack}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="font-bold text-2xl">Subscription edit</h1>
|
|
||||||
<p className="mt-1 text-muted-foreground">
|
|
||||||
Edit subscription #{subscription.id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={handleBack} disabled={updating}>
|
|
||||||
<X className="mr-2 h-4 w-4" />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => form.handleSubmit()} disabled={updating}>
|
<Button onClick={() => form.handleSubmit()} disabled={updating}>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{updating ? 'Saving...' : 'Save'}
|
{updating ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -242,7 +222,7 @@ function FormView({
|
|||||||
<Select
|
<Select
|
||||||
value={field.state.value.toString()}
|
value={field.state.value.toString()}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
field.handleChange(Number.parseInt(value))
|
field.handleChange(Number.parseInt(value, 10))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@@ -274,7 +254,9 @@ function FormView({
|
|||||||
min={1970}
|
min={1970}
|
||||||
onBlur={field.handleBlur}
|
onBlur={field.handleBlur}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.handleChange(Number.parseInt(e.target.value))
|
field.handleChange(
|
||||||
|
Number.parseInt(e.target.value, 10)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
placeholder={`Please enter full year (e.g. ${new Date().getFullYear()})`}
|
placeholder={`Please enter full year (e.g. ${new Date().getFullYear()})`}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -384,7 +366,7 @@ function SubscriptionEditRouteComponent() {
|
|||||||
const { loading, error, data, refetch } =
|
const { loading, error, data, refetch } =
|
||||||
useQuery<GetSubscriptionDetailQuery>(GET_SUBSCRIPTION_DETAIL, {
|
useQuery<GetSubscriptionDetailQuery>(GET_SUBSCRIPTION_DETAIL, {
|
||||||
variables: {
|
variables: {
|
||||||
id: Number.parseInt(id),
|
id: Number.parseInt(id, 10),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -392,10 +374,10 @@ function SubscriptionEditRouteComponent() {
|
|||||||
|
|
||||||
const onCompleted = useCallback(async () => {
|
const onCompleted = useCallback(async () => {
|
||||||
const refetchResult = await refetch();
|
const refetchResult = await refetch();
|
||||||
const error = getApolloQueryError(refetchResult);
|
const _error = getApolloQueryError(refetchResult);
|
||||||
if (error) {
|
if (_error) {
|
||||||
toast.error('Update subscription failed', {
|
toast.error('Update subscription failed', {
|
||||||
description: apolloErrorToMessage(error),
|
description: apolloErrorToMessage(_error),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.success('Update subscription successfully');
|
toast.success('Update subscription successfully');
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
|
import { useMutation, useQuery } from '@apollo/client';
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
type PaginationState,
|
||||||
|
type SortingState,
|
||||||
|
useReactTable,
|
||||||
|
type VisibilityState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ContainerHeader } from '@/components/ui/container-header';
|
||||||
import { DataTablePagination } from '@/components/ui/data-table-pagination';
|
import { DataTablePagination } from '@/components/ui/data-table-pagination';
|
||||||
import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
|
import { DataTableViewOptions } from '@/components/ui/data-table-view-options';
|
||||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog';
|
import { Dialog, DialogTrigger } from '@/components/ui/dialog';
|
||||||
@@ -21,32 +37,20 @@ import {
|
|||||||
type SubscriptionDto,
|
type SubscriptionDto,
|
||||||
UPDATE_SUBSCRIPTIONS,
|
UPDATE_SUBSCRIPTIONS,
|
||||||
} from '@/domains/recorder/schema/subscriptions';
|
} from '@/domains/recorder/schema/subscriptions';
|
||||||
|
import { useInject } from '@/infra/di/inject';
|
||||||
import {
|
import {
|
||||||
apolloErrorToMessage,
|
apolloErrorToMessage,
|
||||||
getApolloQueryError,
|
getApolloQueryError,
|
||||||
} from '@/infra/errors/apollo';
|
} from '@/infra/errors/apollo';
|
||||||
import type { GetSubscriptionsQuery } from '@/infra/graphql/gql/graphql';
|
import type {
|
||||||
|
GetSubscriptionsQuery,
|
||||||
|
GetSubscriptionsQueryVariables,
|
||||||
|
} from '@/infra/graphql/gql/graphql';
|
||||||
|
import { IntlService } from '@/infra/intl/intl.service';
|
||||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
|
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
|
||||||
import { cn } from '@/presentation/utils';
|
import { cn } from '@/presentation/utils';
|
||||||
import { useMutation, useQuery } from '@apollo/client';
|
import { SubscriptionTaskCreationDialogContent } from './-task-creation';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
|
||||||
import {
|
|
||||||
type ColumnDef,
|
|
||||||
type PaginationState,
|
|
||||||
type SortingState,
|
|
||||||
type VisibilityState,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { SubscriptionSyncDialogContent } from './-sync';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/subscriptions/manage')({
|
export const Route = createFileRoute('/_app/subscriptions/manage')({
|
||||||
component: SubscriptionManageRouteComponent,
|
component: SubscriptionManageRouteComponent,
|
||||||
@@ -57,6 +61,7 @@ export const Route = createFileRoute('/_app/subscriptions/manage')({
|
|||||||
|
|
||||||
function SubscriptionManageRouteComponent() {
|
function SubscriptionManageRouteComponent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const intlService = useInject(IntlService);
|
||||||
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
||||||
createdAt: false,
|
createdAt: false,
|
||||||
@@ -68,7 +73,12 @@ function SubscriptionManageRouteComponent() {
|
|||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { loading, error, data, refetch } = useQuery<GetSubscriptionsQuery>(
|
const {
|
||||||
|
loading,
|
||||||
|
error: subscriptionsError,
|
||||||
|
data,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<GetSubscriptionsQuery, GetSubscriptionsQueryVariables>(
|
||||||
GET_SUBSCRIPTIONS,
|
GET_SUBSCRIPTIONS,
|
||||||
{
|
{
|
||||||
variables: {
|
variables: {
|
||||||
@@ -137,11 +147,11 @@ function SubscriptionManageRouteComponent() {
|
|||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
onCheckedChange={(enabled) =>
|
onCheckedChange={(checked) =>
|
||||||
updateSubscription({
|
updateSubscription({
|
||||||
variables: {
|
variables: {
|
||||||
data: {
|
data: {
|
||||||
enabled,
|
enabled: checked,
|
||||||
},
|
},
|
||||||
filter: {
|
filter: {
|
||||||
id: {
|
id: {
|
||||||
@@ -188,7 +198,7 @@ function SubscriptionManageRouteComponent() {
|
|||||||
const createdAt = row.original.createdAt;
|
const createdAt = row.original.createdAt;
|
||||||
return (
|
return (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{format(new Date(createdAt), 'yyyy-MM-dd HH:mm:ss')}
|
{intlService.formatDatetimeWithTz(createdAt)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -200,7 +210,7 @@ function SubscriptionManageRouteComponent() {
|
|||||||
const updatedAt = row.original.updatedAt;
|
const updatedAt = row.original.updatedAt;
|
||||||
return (
|
return (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{format(new Date(updatedAt), 'yyyy-MM-dd HH:mm:ss')}
|
{intlService.formatDatetimeWithTz(updatedAt)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -237,14 +247,21 @@ function SubscriptionManageRouteComponent() {
|
|||||||
Sync
|
Sync
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<SubscriptionSyncDialogContent id={row.original.id} />
|
<SubscriptionTaskCreationDialogContent
|
||||||
|
subscriptionId={row.original.id}
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</DropdownMenuActions>
|
</DropdownMenuActions>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return cs;
|
return cs;
|
||||||
}, [updateSubscription, deleteSubscription, navigate]);
|
}, [
|
||||||
|
updateSubscription,
|
||||||
|
deleteSubscription,
|
||||||
|
navigate,
|
||||||
|
intlService.formatDatetimeWithTz,
|
||||||
|
]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: useMemo(() => subscriptions?.nodes ?? [], [subscriptions]),
|
data: useMemo(() => subscriptions?.nodes ?? [], [subscriptions]),
|
||||||
@@ -271,22 +288,24 @@ function SubscriptionManageRouteComponent() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (subscriptionsError) {
|
||||||
return <QueryErrorView message={error.message} onRetry={refetch} />;
|
return (
|
||||||
|
<QueryErrorView message={subscriptionsError.message} onRetry={refetch} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-4 rounded-md">
|
<div className="container mx-auto space-y-4 rounded-md">
|
||||||
<div className="flex items-center justify-between pt-4">
|
<ContainerHeader
|
||||||
<div>
|
title="Subscription Management"
|
||||||
<h1 className="font-bold text-2xl">Subscription Management</h1>
|
description="Manage your subscription"
|
||||||
<p className="text-muted-foreground">Manage your subscription</p>
|
actions={
|
||||||
</div>
|
<Button onClick={() => navigate({ to: '/subscriptions/create' })}>
|
||||||
<Button onClick={() => navigate({ to: '/subscriptions/create' })}>
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
Add Subscription
|
||||||
Add Subscription
|
</Button>
|
||||||
</Button>
|
}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center py-2">
|
<div className="flex items-center py-2">
|
||||||
<DataTableViewOptions table={table} />
|
<DataTableViewOptions table={table} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { CronStatusEnum } from '@/infra/graphql/gql/graphql';
|
||||||
|
import { AlertCircle, CheckCircle, Clock, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export function getStatusBadge(status: CronStatusEnum) {
|
||||||
|
switch (status) {
|
||||||
|
case CronStatusEnum.Completed:
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="bg-green-100 text-green-800">
|
||||||
|
<CheckCircle className="mr-1 h-3 w-3 capitalize" />
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case CronStatusEnum.Running:
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="bg-blue-100 text-blue-800">
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin capitalize" />
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case CronStatusEnum.Failed:
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
<AlertCircle className="mr-1 h-3 w-3 capitalize" />
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case CronStatusEnum.Pending:
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
|
||||||
|
<Clock className="mr-1 h-3 w-3 capitalize" />
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { useQuery } from '@apollo/client';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { ContainerHeader } from '@/components/ui/container-header';
|
||||||
|
import { CronDisplay } from '@/components/ui/cron';
|
||||||
|
import { DetailCardSkeleton } from '@/components/ui/detail-card-skeleton';
|
||||||
|
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { QueryErrorView } from '@/components/ui/query-error-view';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { GET_CRONS } from '@/domains/recorder/schema/cron';
|
||||||
|
import { useInject } from '@/infra/di/inject';
|
||||||
|
import {
|
||||||
|
CronStatusEnum,
|
||||||
|
type GetCronsQuery,
|
||||||
|
type GetCronsQueryVariables,
|
||||||
|
} from '@/infra/graphql/gql/graphql';
|
||||||
|
import { IntlService } from '@/infra/intl/intl.service';
|
||||||
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
|
import { getStatusBadge } from './-status-badge';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_app/tasks/cron/detail/$id')({
|
||||||
|
component: CronDetailRouteComponent,
|
||||||
|
staticData: {
|
||||||
|
breadcrumb: { label: 'Detail' },
|
||||||
|
} satisfies RouteStateDataOption,
|
||||||
|
});
|
||||||
|
|
||||||
|
function CronDetailRouteComponent() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const intlService = useInject(IntlService);
|
||||||
|
|
||||||
|
const { data, loading, error, refetch } = useQuery<
|
||||||
|
GetCronsQuery,
|
||||||
|
GetCronsQueryVariables
|
||||||
|
>(GET_CRONS, {
|
||||||
|
variables: {
|
||||||
|
filter: {
|
||||||
|
id: {
|
||||||
|
eq: Number.parseInt(id, 10),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
page: {
|
||||||
|
page: 0,
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {},
|
||||||
|
},
|
||||||
|
pollInterval: 5000, // Auto-refresh every 5 seconds for running crons
|
||||||
|
});
|
||||||
|
|
||||||
|
const cron = data?.cron?.nodes?.[0];
|
||||||
|
|
||||||
|
const subscriberTaskCron = useMemo(() => {
|
||||||
|
if (!cron) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cron.subscriberTaskCron;
|
||||||
|
}, [cron]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <DetailCardSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <QueryErrorView message={error.message} onRetry={refetch} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cron) {
|
||||||
|
return <DetailEmptyView message="Not found Cron task" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-4xl py-6">
|
||||||
|
<ContainerHeader
|
||||||
|
title="Cron task detail"
|
||||||
|
description={`View Cron task #${cron.id}`}
|
||||||
|
defaultBackTo="/tasks/cron/manage"
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Cron task information</CardTitle>
|
||||||
|
<CardDescription className="mt-2">
|
||||||
|
View Cron task execution details
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getStatusBadge(cron.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Basic Information */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">ID</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<code className="text-sm">{cron.id}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">Priority</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<span className="text-sm">{cron.priority}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">Attemps</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{cron.attempts} / {cron.maxAttempts}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">Enabled</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<Badge variant={cron.enabled ? 'default' : 'secondary'}>
|
||||||
|
{cron.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">Next run time</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{cron.nextRun
|
||||||
|
? intlService.formatDatetimeWithTz(cron.nextRun)
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">Last run time</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{cron.lastRun
|
||||||
|
? intlService.formatDatetimeWithTz(cron.lastRun)
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">Locked time</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{cron.lockedAt
|
||||||
|
? intlService.formatDatetimeWithTz(cron.lockedAt)
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">Locked by</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<code className="text-sm">{cron.lockedBy || '-'}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">Timeout</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{cron.timeoutMs ? `${cron.timeoutMs}ms` : 'No limit'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">Created at</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{intlService.formatDatetimeWithTz(cron.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">Updated at</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<span className="text-sm">
|
||||||
|
{intlService.formatDatetimeWithTz(cron.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscriber Task Details */}
|
||||||
|
{subscriberTaskCron && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">
|
||||||
|
Subscriber task details
|
||||||
|
</Label>
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap text-sm">
|
||||||
|
<code>
|
||||||
|
{JSON.stringify(subscriberTaskCron, null, 2)}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cron Expression Display */}
|
||||||
|
{cron.cronExpr && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">
|
||||||
|
Cron expression
|
||||||
|
</Label>
|
||||||
|
<CronDisplay
|
||||||
|
expression={cron.cronExpr}
|
||||||
|
timezone="UTC"
|
||||||
|
showDescription={true}
|
||||||
|
showNextRuns={true}
|
||||||
|
withCard={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Related Subscriber Tasks */}
|
||||||
|
{cron.subscriberTask?.nodes &&
|
||||||
|
cron.subscriberTask.nodes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">
|
||||||
|
Associated tasks
|
||||||
|
</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{cron.subscriberTask.nodes.map((task, index) => (
|
||||||
|
<div
|
||||||
|
key={`${task.id}-${index}`}
|
||||||
|
className="rounded-md border bg-muted/50 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<code className="text-sm">{task.id}</code>
|
||||||
|
<Badge variant="outline">{task.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-muted-foreground text-sm">
|
||||||
|
Priority: {task.priority} | Attempts:{' '}
|
||||||
|
{task.attempts}/{task.maxAttempts}
|
||||||
|
</div>
|
||||||
|
{task.subscription && (
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
Subscription:
|
||||||
|
</span>{' '}
|
||||||
|
{task.subscription.displayName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Information */}
|
||||||
|
{cron.status === CronStatusEnum.Failed && cron.lastError && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="font-medium text-sm">最后错误</Label>
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3">
|
||||||
|
<p className="text-destructive text-sm">
|
||||||
|
{cron.lastError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_app/tasks/cron/edit/$id')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <div>Hello "/_app/tasks/cron/edit/$id"!</div>
|
||||||
|
}
|
||||||
297
apps/webui/src/presentation/routes/_app/tasks/cron/manage.tsx
Normal file
297
apps/webui/src/presentation/routes/_app/tasks/cron/manage.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { useMutation, useQuery } from '@apollo/client';
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
type PaginationState,
|
||||||
|
type SortingState,
|
||||||
|
useReactTable,
|
||||||
|
type VisibilityState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ContainerHeader } from '@/components/ui/container-header';
|
||||||
|
import { DataTablePagination } from '@/components/ui/data-table-pagination';
|
||||||
|
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
||||||
|
import { DropdownMenuActions } from '@/components/ui/dropdown-menu-actions';
|
||||||
|
import { QueryErrorView } from '@/components/ui/query-error-view';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
type CronDto,
|
||||||
|
DELETE_CRONS,
|
||||||
|
GET_CRONS,
|
||||||
|
} from '@/domains/recorder/schema/cron';
|
||||||
|
import { useInject } from '@/infra/di/inject';
|
||||||
|
import {
|
||||||
|
apolloErrorToMessage,
|
||||||
|
getApolloQueryError,
|
||||||
|
} from '@/infra/errors/apollo';
|
||||||
|
import {
|
||||||
|
CronStatusEnum,
|
||||||
|
type DeleteCronsMutation,
|
||||||
|
type DeleteCronsMutationVariables,
|
||||||
|
type GetCronsQuery,
|
||||||
|
type GetCronsQueryVariables,
|
||||||
|
} from '@/infra/graphql/gql/graphql';
|
||||||
|
import { IntlService } from '@/infra/intl/intl.service';
|
||||||
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
|
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
|
||||||
|
import { getStatusBadge } from './-status-badge';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_app/tasks/cron/manage')({
|
||||||
|
component: TaskCronManageRouteComponent,
|
||||||
|
staticData: {
|
||||||
|
breadcrumb: { label: 'Manage' },
|
||||||
|
} satisfies RouteStateDataOption,
|
||||||
|
});
|
||||||
|
|
||||||
|
function TaskCronManageRouteComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const intlService = useInject(IntlService);
|
||||||
|
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { loading, error, data, refetch } = useQuery<
|
||||||
|
GetCronsQuery,
|
||||||
|
GetCronsQueryVariables
|
||||||
|
>(GET_CRONS, {
|
||||||
|
variables: {
|
||||||
|
pagination: {
|
||||||
|
page: {
|
||||||
|
page: pagination.pageIndex,
|
||||||
|
limit: pagination.pageSize,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
filter: {},
|
||||||
|
orderBy: {
|
||||||
|
nextRun: 'DESC',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pollInterval: 5000, // Auto-refresh every 5 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showSkeleton } = useDebouncedSkeleton({ loading });
|
||||||
|
|
||||||
|
const crons = data?.cron;
|
||||||
|
|
||||||
|
const [deleteCron] = useMutation<
|
||||||
|
DeleteCronsMutation,
|
||||||
|
DeleteCronsMutationVariables
|
||||||
|
>(DELETE_CRONS, {
|
||||||
|
onCompleted: async () => {
|
||||||
|
const refetchResult = await refetch();
|
||||||
|
const errorResult = getApolloQueryError(refetchResult);
|
||||||
|
if (errorResult) {
|
||||||
|
toast.error('Failed to delete tasks', {
|
||||||
|
description: apolloErrorToMessage(errorResult),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Tasks deleted');
|
||||||
|
},
|
||||||
|
onError: (mutationError) => {
|
||||||
|
toast.error('Failed to delete tasks', {
|
||||||
|
description: mutationError.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
const cs: ColumnDef<CronDto>[] = [
|
||||||
|
{
|
||||||
|
header: 'ID',
|
||||||
|
accessorKey: 'id',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="max-w-[200px] truncate font-mono text-sm"
|
||||||
|
title={row.original.id.toString()}
|
||||||
|
>
|
||||||
|
{row.original.id}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return cs;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: useMemo(() => (crons?.nodes ?? []) as CronDto[], [crons]),
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
pageCount: crons?.paginationInfo?.pages,
|
||||||
|
rowCount: crons?.paginationInfo?.total,
|
||||||
|
enableColumnPinning: true,
|
||||||
|
autoResetPageIndex: true,
|
||||||
|
manualPagination: true,
|
||||||
|
state: {
|
||||||
|
pagination,
|
||||||
|
sorting,
|
||||||
|
columnVisibility,
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
columnPinning: {
|
||||||
|
right: ['actions'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <QueryErrorView message={error.message} onRetry={refetch} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-4xl space-y-4 px-4">
|
||||||
|
<ContainerHeader
|
||||||
|
title="Crons Management"
|
||||||
|
description="Manage your crons"
|
||||||
|
actions={
|
||||||
|
<Button onClick={() => refetch()} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{showSkeleton &&
|
||||||
|
Array.from(new Array(10)).map((_, index) => (
|
||||||
|
<Skeleton key={`skeleton-${index}`} className="h-32 w-full" />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!showSkeleton && table.getRowModel().rows?.length > 0 ? (
|
||||||
|
table.getRowModel().rows.map((row) => {
|
||||||
|
const cron = row.original;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-y-3 rounded-lg border bg-card p-4"
|
||||||
|
key={cron.id}
|
||||||
|
>
|
||||||
|
{/* Header with status and priority */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="font-mono text-muted-foreground text-xs">
|
||||||
|
# {cron.id}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge variant="outline" className="capitalize">
|
||||||
|
{cron.cronExpr}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
{getStatusBadge(cron.status)}
|
||||||
|
<Badge variant="outline">Priority: {cron.priority}</Badge>
|
||||||
|
<div className="mr-0 ml-auto">
|
||||||
|
<DropdownMenuActions
|
||||||
|
id={cron.id}
|
||||||
|
showDetail
|
||||||
|
onDetail={() => {
|
||||||
|
navigate({
|
||||||
|
to: '/tasks/cron/detail/$id',
|
||||||
|
params: { id: cron.id.toString() },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
showDelete
|
||||||
|
onDelete={() =>
|
||||||
|
deleteCron({
|
||||||
|
variables: {
|
||||||
|
filter: {
|
||||||
|
id: {
|
||||||
|
eq: cron.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time info */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Next run: </span>
|
||||||
|
<span>
|
||||||
|
{cron.nextRun
|
||||||
|
? intlService.formatDatetimeWithTz(cron.nextRun)
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Last run: </span>
|
||||||
|
<span>
|
||||||
|
{cron.lastRun
|
||||||
|
? intlService.formatDatetimeWithTz(cron.lastRun)
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attempts */}
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Attempts: </span>
|
||||||
|
<span>
|
||||||
|
{cron.attempts} / {cron.maxAttempts}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lock at */}
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Lock at: </span>
|
||||||
|
<span>
|
||||||
|
{cron.lockedAt
|
||||||
|
? intlService.formatDatetimeWithTz(cron.lockedAt)
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscriber task cron */}
|
||||||
|
{cron.subscriberTaskCron && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Task:</span>
|
||||||
|
<br />
|
||||||
|
<span
|
||||||
|
className="whitespace-pre-wrap"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify(
|
||||||
|
cron.subscriberTaskCron,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error if exists */}
|
||||||
|
{cron.status === CronStatusEnum.Failed && cron.lastError && (
|
||||||
|
<div className="rounded bg-destructive/10 p-2 text-destructive text-sm">
|
||||||
|
{cron.lastError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<DetailEmptyView message="No tasks found" fullWidth />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTablePagination table={table} showSelectedRowCount={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { buildVirtualBranchRouteOptions } from '@/infra/routes/utils';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_app/tasks/cron')(
|
||||||
|
buildVirtualBranchRouteOptions({
|
||||||
|
title: 'Cron',
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import { DetailCardSkeleton } from '@/components/detail-card-skeleton';
|
import { useMutation, useQuery } from '@apollo/client';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
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 {
|
||||||
@@ -8,13 +12,18 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
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';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { GET_TASKS, RETRY_TASKS } from '@/domains/recorder/schema/tasks';
|
import { GET_TASKS, RETRY_TASKS } from '@/domains/recorder/schema/tasks';
|
||||||
import { getApolloQueryError } from '@/infra/errors/apollo';
|
import { useInject } from '@/infra/di/inject';
|
||||||
import { apolloErrorToMessage } from '@/infra/errors/apollo';
|
import {
|
||||||
|
apolloErrorToMessage,
|
||||||
|
getApolloQueryError,
|
||||||
|
} from '@/infra/errors/apollo';
|
||||||
import {
|
import {
|
||||||
type GetTasksQuery,
|
type GetTasksQuery,
|
||||||
type GetTasksQueryVariables,
|
type GetTasksQueryVariables,
|
||||||
@@ -22,18 +31,8 @@ import {
|
|||||||
type RetryTasksMutationVariables,
|
type RetryTasksMutationVariables,
|
||||||
SubscriberTaskStatusEnum,
|
SubscriberTaskStatusEnum,
|
||||||
} from '@/infra/graphql/gql/graphql';
|
} from '@/infra/graphql/gql/graphql';
|
||||||
|
import { IntlService } from '@/infra/intl/intl.service';
|
||||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
import { useMutation, useQuery } from '@apollo/client';
|
|
||||||
import {
|
|
||||||
createFileRoute,
|
|
||||||
useCanGoBack,
|
|
||||||
useNavigate,
|
|
||||||
useRouter,
|
|
||||||
} from '@tanstack/react-router';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { ArrowLeft, RefreshCw } from 'lucide-react';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { prettyTaskType } from './-pretty-task-type';
|
import { prettyTaskType } from './-pretty-task-type';
|
||||||
import { getStatusBadge } from './-status-badge';
|
import { getStatusBadge } from './-status-badge';
|
||||||
|
|
||||||
@@ -46,24 +45,15 @@ export const Route = createFileRoute('/_app/tasks/detail/$id')({
|
|||||||
|
|
||||||
function TaskDetailRouteComponent() {
|
function TaskDetailRouteComponent() {
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const navigate = useNavigate();
|
|
||||||
const router = useRouter();
|
|
||||||
const canGoBack = useCanGoBack();
|
|
||||||
|
|
||||||
const handleBack = () => {
|
const intlService = useInject(IntlService);
|
||||||
if (canGoBack) {
|
|
||||||
router.history.back();
|
|
||||||
} else {
|
|
||||||
navigate({
|
|
||||||
to: '/tasks/manage',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data, loading, error, refetch } = useQuery<
|
const {
|
||||||
GetTasksQuery,
|
data,
|
||||||
GetTasksQueryVariables
|
loading,
|
||||||
>(GET_TASKS, {
|
error: taskError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<GetTasksQuery, GetTasksQueryVariables>(GET_TASKS, {
|
||||||
variables: {
|
variables: {
|
||||||
filter: {
|
filter: {
|
||||||
id: {
|
id: {
|
||||||
@@ -119,8 +109,8 @@ function TaskDetailRouteComponent() {
|
|||||||
return <DetailCardSkeleton />;
|
return <DetailCardSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (taskError) {
|
||||||
return <QueryErrorView message={error.message} onRetry={refetch} />;
|
return <QueryErrorView message={taskError.message} onRetry={refetch} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
@@ -129,27 +119,17 @@ function TaskDetailRouteComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl py-6">
|
<div className="container mx-auto max-w-4xl py-6">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<ContainerHeader
|
||||||
<div className="flex items-center gap-4">
|
title="Task Detail"
|
||||||
<Button
|
description={`View task #${task.id}`}
|
||||||
variant="ghost"
|
defaultBackTo="/tasks/manage"
|
||||||
size="sm"
|
actions={
|
||||||
onClick={handleBack}
|
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||||
className="h-8 w-8 p-0"
|
<RefreshCw className="h-4 w-4" />
|
||||||
>
|
Refresh
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
}
|
||||||
<h1 className="font-bold text-2xl">Task Detail</h1>
|
/>
|
||||||
<p className="mt-1 text-muted-foreground">View task #{task.id}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -222,7 +202,7 @@ function TaskDetailRouteComponent() {
|
|||||||
</Label>
|
</Label>
|
||||||
<div className="rounded-md bg-muted p-3">
|
<div className="rounded-md bg-muted p-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{format(new Date(task.runAt), 'yyyy-MM-dd HH:mm:ss')}
|
{intlService.formatDatetimeWithTz(task.runAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,7 +212,7 @@ function TaskDetailRouteComponent() {
|
|||||||
<div className="rounded-md bg-muted p-3">
|
<div className="rounded-md bg-muted p-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{task.doneAt
|
{task.doneAt
|
||||||
? format(new Date(task.doneAt), 'yyyy-MM-dd HH:mm:ss')
|
? intlService.formatDatetimeWithTz(task.doneAt)
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,7 +223,7 @@ function TaskDetailRouteComponent() {
|
|||||||
<div className="rounded-md bg-muted p-3">
|
<div className="rounded-md bg-muted p-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{task.lockAt
|
{task.lockAt
|
||||||
? format(new Date(task.lockAt), 'yyyy-MM-dd HH:mm:ss')
|
? intlService.formatDatetimeWithTz(task.lockAt)
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
|
import { useMutation, useQuery } from '@apollo/client';
|
||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
type PaginationState,
|
||||||
|
type SortingState,
|
||||||
|
useReactTable,
|
||||||
|
type VisibilityState,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
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 { ContainerHeader } from '@/components/ui/container-header';
|
||||||
import { DataTablePagination } from '@/components/ui/data-table-pagination';
|
import { DataTablePagination } from '@/components/ui/data-table-pagination';
|
||||||
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
import { DetailEmptyView } from '@/components/ui/detail-empty-view';
|
||||||
|
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||||
import { DropdownMenuActions } from '@/components/ui/dropdown-menu-actions';
|
import { DropdownMenuActions } from '@/components/ui/dropdown-menu-actions';
|
||||||
import { QueryErrorView } from '@/components/ui/query-error-view';
|
import { QueryErrorView } from '@/components/ui/query-error-view';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -11,37 +27,23 @@ import {
|
|||||||
RETRY_TASKS,
|
RETRY_TASKS,
|
||||||
type TaskDto,
|
type TaskDto,
|
||||||
} from '@/domains/recorder/schema/tasks';
|
} from '@/domains/recorder/schema/tasks';
|
||||||
import {
|
import { useInject } from '@/infra/di/inject';
|
||||||
type DeleteTasksMutation,
|
|
||||||
type DeleteTasksMutationVariables,
|
|
||||||
type GetTasksQuery,
|
|
||||||
type RetryTasksMutation,
|
|
||||||
type RetryTasksMutationVariables,
|
|
||||||
SubscriberTaskStatusEnum,
|
|
||||||
} from '@/infra/graphql/gql/graphql';
|
|
||||||
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
|
||||||
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
|
|
||||||
import { useMutation, useQuery } from '@apollo/client';
|
|
||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
|
||||||
import {
|
|
||||||
type ColumnDef,
|
|
||||||
type PaginationState,
|
|
||||||
type SortingState,
|
|
||||||
type VisibilityState,
|
|
||||||
getCoreRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { RefreshCw } from 'lucide-react';
|
|
||||||
|
|
||||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
|
||||||
import {
|
import {
|
||||||
apolloErrorToMessage,
|
apolloErrorToMessage,
|
||||||
getApolloQueryError,
|
getApolloQueryError,
|
||||||
} from '@/infra/errors/apollo';
|
} from '@/infra/errors/apollo';
|
||||||
import { useMemo, useState } from 'react';
|
import {
|
||||||
import { toast } from 'sonner';
|
type DeleteTasksMutation,
|
||||||
|
type DeleteTasksMutationVariables,
|
||||||
|
type GetTasksQuery,
|
||||||
|
type GetTasksQueryVariables,
|
||||||
|
type RetryTasksMutation,
|
||||||
|
type RetryTasksMutationVariables,
|
||||||
|
SubscriberTaskStatusEnum,
|
||||||
|
} from '@/infra/graphql/gql/graphql';
|
||||||
|
import { IntlService } from '@/infra/intl/intl.service';
|
||||||
|
import type { RouteStateDataOption } from '@/infra/routes/traits';
|
||||||
|
import { useDebouncedSkeleton } from '@/presentation/hooks/use-debounded-skeleton';
|
||||||
import { prettyTaskType } from './-pretty-task-type';
|
import { prettyTaskType } from './-pretty-task-type';
|
||||||
import { getStatusBadge } from './-status-badge';
|
import { getStatusBadge } from './-status-badge';
|
||||||
|
|
||||||
@@ -55,18 +57,21 @@ export const Route = createFileRoute('/_app/tasks/manage')({
|
|||||||
function TaskManageRouteComponent() {
|
function TaskManageRouteComponent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
lockAt: false,
|
|
||||||
lockBy: false,
|
|
||||||
attempts: false,
|
|
||||||
});
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { loading, error, data, refetch } = useQuery<GetTasksQuery>(GET_TASKS, {
|
const intlService = useInject(IntlService);
|
||||||
|
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
error: tasksError,
|
||||||
|
data,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<GetTasksQuery, GetTasksQueryVariables>(GET_TASKS, {
|
||||||
variables: {
|
variables: {
|
||||||
pagination: {
|
pagination: {
|
||||||
page: {
|
page: {
|
||||||
@@ -167,21 +172,21 @@ function TaskManageRouteComponent() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (tasksError) {
|
||||||
return <QueryErrorView message={error.message} onRetry={refetch} />;
|
return <QueryErrorView message={tasksError.message} onRetry={refetch} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-4 px-4">
|
<div className="container mx-auto max-w-4xl space-y-4 px-4">
|
||||||
<div className="flex items-center justify-between pt-4">
|
<ContainerHeader
|
||||||
<div>
|
title="Tasks Management"
|
||||||
<h1 className="font-bold text-2xl">Tasks Management</h1>
|
description="Manage your tasks"
|
||||||
<p className="text-muted-foreground">Manage your tasks</p>
|
actions={
|
||||||
</div>
|
<Button onClick={() => refetch()} variant="outline" size="sm">
|
||||||
<Button onClick={() => refetch()} variant="outline" size="sm">
|
<RefreshCw className="h-4 w-4" />
|
||||||
<RefreshCw className="h-4 w-4" />
|
</Button>
|
||||||
</Button>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{showSkeleton &&
|
{showSkeleton &&
|
||||||
@@ -261,14 +266,14 @@ function TaskManageRouteComponent() {
|
|||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Run at: </span>
|
<span className="text-muted-foreground">Run at: </span>
|
||||||
<span>{format(new Date(task.runAt), 'MM/dd HH:mm')}</span>
|
<span>{intlService.formatDatetimeWithTz(task.runAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Done: </span>
|
<span className="text-muted-foreground">Done: </span>
|
||||||
<span>
|
<span>
|
||||||
{task.doneAt
|
{task.doneAt
|
||||||
? format(new Date(task.doneAt), 'MM/dd HH:mm')
|
? intlService.formatDatetimeWithTz(task.doneAt)
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,7 +291,7 @@ function TaskManageRouteComponent() {
|
|||||||
<span className="text-muted-foreground">Lock at: </span>
|
<span className="text-muted-foreground">Lock at: </span>
|
||||||
<span>
|
<span>
|
||||||
{task.lockAt
|
{task.lockAt
|
||||||
? format(new Date(task.lockAt), 'MM/dd HH:mm')
|
? intlService.formatDatetimeWithTz(task.lockAt)
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
56
biome.json
56
biome.json
@@ -1,31 +1,39 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.1.1/schema.json",
|
||||||
"extends": ["ultracite"],
|
"extends": ["ultracite"],
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"globals": ["Liveblocks"]
|
"globals": ["Liveblocks"]
|
||||||
},
|
},
|
||||||
|
"assist": {
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"useSortedAttributes": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"nursery": {
|
"nursery": {},
|
||||||
"noEnum": "off"
|
|
||||||
},
|
|
||||||
"style": {
|
"style": {
|
||||||
"noParameterProperties": "off",
|
"noParameterProperties": "off",
|
||||||
"noNonNullAssertion": "off"
|
"noNonNullAssertion": "off",
|
||||||
|
"noEnum": "off"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"noDangerouslySetInnerHtml": "off"
|
"noDangerouslySetInnerHtml": "off"
|
||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
|
"noArrayIndexKey": "off",
|
||||||
"noEmptyBlockStatements": "off",
|
"noEmptyBlockStatements": "off",
|
||||||
"noExplicitAny": "off",
|
"noExplicitAny": "off",
|
||||||
"noConsole": "off",
|
"noConsole": "off"
|
||||||
"noConsoleLog": "off"
|
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
"noSvgWithoutTitle": "off"
|
"noSvgWithoutTitle": "off"
|
||||||
},
|
},
|
||||||
|
"performance": {
|
||||||
|
"noNamespaceImport": "off"
|
||||||
|
},
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noExcessiveCognitiveComplexity": {
|
"noExcessiveCognitiveComplexity": {
|
||||||
"level": "warn",
|
"level": "warn",
|
||||||
@@ -36,20 +44,38 @@
|
|||||||
"noBannedTypes": "off"
|
"noBannedTypes": "off"
|
||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
|
"noUnusedVariables": {
|
||||||
|
"fix": "none",
|
||||||
|
"level": "error"
|
||||||
|
},
|
||||||
"noUnusedImports": {
|
"noUnusedImports": {
|
||||||
"fix": "none",
|
"fix": "none",
|
||||||
"level": "warn"
|
"level": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"include": ["apps/webui/src/infra/graphql/gql/**/*"],
|
"includes": ["**/tsconfig.json", "**/tsconfig.*.json"],
|
||||||
|
"json": {
|
||||||
|
"parser": {
|
||||||
|
"allowComments": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["**/apps/webui/src/infra/graphql/gql/**/*"],
|
||||||
|
"assist": {
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"style": {
|
"style": {
|
||||||
"useShorthandArrayType": "off",
|
|
||||||
"useConsistentArrayType": "off",
|
"useConsistentArrayType": "off",
|
||||||
"useImportType": "off"
|
"useImportType": "off"
|
||||||
}
|
}
|
||||||
@@ -57,7 +83,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"include": ["apps/webui/src/components/ui/**/*"],
|
"includes": ["**/apps/webui/src/components/ui/**/*"],
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"quoteStyle": "double"
|
"quoteStyle": "double"
|
||||||
@@ -67,10 +93,10 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"style": {
|
"style": {
|
||||||
"useBlockStatements": "off",
|
"useBlockStatements": "off",
|
||||||
"useImportType": "off"
|
"useImportType": "off",
|
||||||
|
"noNestedTernary": "off"
|
||||||
},
|
},
|
||||||
"nursery": {
|
"nursery": {
|
||||||
"noNestedTernary": "off",
|
|
||||||
"useSortedClasses": "off"
|
"useSortedClasses": "off"
|
||||||
},
|
},
|
||||||
"a11y": {
|
"a11y": {
|
||||||
@@ -86,6 +112,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"files": {
|
"files": {
|
||||||
"ignore": [".vscode/*.json"]
|
"includes": ["**", "!**/.vscode/**/*.json"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,22 +18,23 @@
|
|||||||
"bump-deps": "npx --yes npm-check-updates --deep -u && pnpm install",
|
"bump-deps": "npx --yes npm-check-updates --deep -u && pnpm install",
|
||||||
"clean": "git clean -xdf node_modules"
|
"clean": "git clean -xdf node_modules"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.12.1",
|
"packageManager": "pnpm@10.12.4",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@typescript/native-preview": "7.0.0-dev.20250712.1",
|
||||||
"es-toolkit": "^1.39.6"
|
"es-toolkit": "^1.39.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "2.1.1",
|
||||||
"@types/node": "^24.0.10",
|
"@types/node": "^24.0.10",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"kill-port": "^2.0.1",
|
"kill-port": "^2.0.1",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"ultracite": "^4.2.13"
|
"ultracite": "^5.0.32"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
447
pnpm-lock.yaml
generated
447
pnpm-lock.yaml
generated
@@ -11,13 +11,16 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@typescript/native-preview':
|
||||||
|
specifier: 7.0.0-dev.20250712.1
|
||||||
|
version: 7.0.0-dev.20250712.1
|
||||||
es-toolkit:
|
es-toolkit:
|
||||||
specifier: ^1.39.6
|
specifier: ^1.39.6
|
||||||
version: 1.39.6
|
version: 1.39.6
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 1.9.4
|
specifier: 2.1.1
|
||||||
version: 1.9.4
|
version: 2.1.1
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.0.10
|
specifier: ^24.0.10
|
||||||
version: 24.0.10
|
version: 24.0.10
|
||||||
@@ -37,8 +40,8 @@ importers:
|
|||||||
specifier: ^5.8.3
|
specifier: ^5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
ultracite:
|
ultracite:
|
||||||
specifier: ^4.2.13
|
specifier: ^5.0.32
|
||||||
version: 4.2.13
|
version: 5.0.32(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
|
||||||
|
|
||||||
apps/docs: {}
|
apps/docs: {}
|
||||||
|
|
||||||
@@ -89,6 +92,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 +272,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
|
||||||
@@ -592,59 +604,65 @@ packages:
|
|||||||
resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==}
|
resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@biomejs/biome@1.9.4':
|
'@biomejs/biome@2.1.1':
|
||||||
resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==}
|
resolution: {integrity: sha512-HFGYkxG714KzG+8tvtXCJ1t1qXQMzgWzfvQaUjxN6UeKv+KvMEuliInnbZLJm6DXFXwqVi6446EGI0sGBLIYng==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@1.9.4':
|
'@biomejs/cli-darwin-arm64@2.1.1':
|
||||||
resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==}
|
resolution: {integrity: sha512-2Muinu5ok4tWxq4nu5l19el48cwCY/vzvI7Vjbkf3CYIQkjxZLyj0Ad37Jv2OtlXYaLvv+Sfu1hFeXt/JwRRXQ==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@1.9.4':
|
'@biomejs/cli-darwin-x64@2.1.1':
|
||||||
resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==}
|
resolution: {integrity: sha512-cC8HM5lrgKQXLAK+6Iz2FrYW5A62pAAX6KAnRlEyLb+Q3+Kr6ur/sSuoIacqlp1yvmjHJqjYfZjPvHWnqxoEIA==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@1.9.4':
|
'@biomejs/cli-linux-arm64-musl@2.1.1':
|
||||||
resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==}
|
resolution: {integrity: sha512-/7FBLnTswu4jgV9ttI3AMIdDGqVEPIZd8I5u2D4tfCoj8rl9dnjrEQbAIDlWhUXdyWlFSz8JypH3swU9h9P+2A==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@1.9.4':
|
'@biomejs/cli-linux-arm64@2.1.1':
|
||||||
resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==}
|
resolution: {integrity: sha512-tw4BEbhAUkWPe4WBr6IX04DJo+2jz5qpPzpW/SWvqMjb9QuHY8+J0M23V8EPY/zWU4IG8Ui0XESapR1CB49Q7g==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@1.9.4':
|
'@biomejs/cli-linux-x64-musl@2.1.1':
|
||||||
resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==}
|
resolution: {integrity: sha512-kUu+loNI3OCD2c12cUt7M5yaaSjDnGIksZwKnueubX6c/HWUyi/0mPbTBHR49Me3F0KKjWiKM+ZOjsmC+lUt9g==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@1.9.4':
|
'@biomejs/cli-linux-x64@2.1.1':
|
||||||
resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==}
|
resolution: {integrity: sha512-3WJ1GKjU7NzZb6RTbwLB59v9cTIlzjbiFLDB0z4376TkDqoNYilJaC37IomCr/aXwuU8QKkrYoHrgpSq5ffJ4Q==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@1.9.4':
|
'@biomejs/cli-win32-arm64@2.1.1':
|
||||||
resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==}
|
resolution: {integrity: sha512-vEHK0v0oW+E6RUWLoxb2isI3rZo57OX9ZNyyGH701fZPj6Il0Rn1f5DMNyCmyflMwTnIQstEbs7n2BxYSqQx4Q==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@1.9.4':
|
'@biomejs/cli-win32-x64@2.1.1':
|
||||||
resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==}
|
resolution: {integrity: sha512-i2PKdn70kY++KEF/zkQFvQfX1e8SkA8hq4BgC+yE9dZqyLzB/XStY2MvwI3qswlRgnGpgncgqe0QYKVS1blksg==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@clack/core@0.5.0':
|
||||||
|
resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==}
|
||||||
|
|
||||||
|
'@clack/prompts@0.11.0':
|
||||||
|
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
|
||||||
|
|
||||||
'@codemirror/language@6.11.1':
|
'@codemirror/language@6.11.1':
|
||||||
resolution: {integrity: sha512-5kS1U7emOGV84vxC+ruBty5sUgcD0te6dyupyRVG2zaSjhTDM73LhVKUtVwiqSe6QwmEoA4SCiU8AKPFyumAWQ==}
|
resolution: {integrity: sha512-5kS1U7emOGV84vxC+ruBty5sUgcD0te6dyupyRVG2zaSjhTDM73LhVKUtVwiqSe6QwmEoA4SCiU8AKPFyumAWQ==}
|
||||||
|
|
||||||
@@ -712,6 +730,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 +3191,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:
|
||||||
@@ -3184,6 +3211,53 @@ packages:
|
|||||||
'@types/ws@8.18.1':
|
'@types/ws@8.18.1':
|
||||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||||
|
|
||||||
|
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20250712.1':
|
||||||
|
resolution: {integrity: sha512-h5MlpLXr6I6zrKZKZOjyKdUhzUuO2+kKLEYmrR0HWS/U6dlcwvNuS2wUo1lRNgUTCblHJuOKmyWx3Sz+JG8Oxw==}
|
||||||
|
engines: {node: '>=20.6.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@typescript/native-preview-darwin-x64@7.0.0-dev.20250712.1':
|
||||||
|
resolution: {integrity: sha512-72SPfl0/U2ra4KlkzLWgA86zLKUIimBPYE1NqZpJHLMoXkC5XtS9aXT8p6ivxkK+p1VV3fIWKM8BbxOUY3af0A==}
|
||||||
|
engines: {node: '>=20.6.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@typescript/native-preview-linux-arm64@7.0.0-dev.20250712.1':
|
||||||
|
resolution: {integrity: sha512-LqIRXAn1xC5amD+ypd7xVTyMhgbbhb9XLzLM32Gr8ogJUCvcLBbd8KCHsKnHSR8nc+3b9FKnYdK7YL6a2IqavA==}
|
||||||
|
engines: {node: '>=20.6.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@typescript/native-preview-linux-arm@7.0.0-dev.20250712.1':
|
||||||
|
resolution: {integrity: sha512-XedHV/oRfLKrfU7XE5/Fz8Lf+eKCaQyNINCikQQlAuQWgT4pf8gW9FPkZYmeNvl7y+++43Wr2YJklmwXMrIDiA==}
|
||||||
|
engines: {node: '>=20.6.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@typescript/native-preview-linux-x64@7.0.0-dev.20250712.1':
|
||||||
|
resolution: {integrity: sha512-dCIb4GTXvetTpeVJRsKf0lNnq1udfjkhDmyUX15yWV41mPg+PJUiLeZty2GOwFovSfEUSK+pQSP2iaA6ITbLtw==}
|
||||||
|
engines: {node: '>=20.6.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@typescript/native-preview-win32-arm64@7.0.0-dev.20250712.1':
|
||||||
|
resolution: {integrity: sha512-+Wze9OFlre7YxOh/2LePfh6TmdwMJkSyiFD6XRtmm1hkwoB8jk5h1Q5aC5P4a3LTYx6jie6eYSVSYUXtaWZqMw==}
|
||||||
|
engines: {node: '>=20.6.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@typescript/native-preview-win32-x64@7.0.0-dev.20250712.1':
|
||||||
|
resolution: {integrity: sha512-PwLTlosngLgI4O41qjIFFanl5Q+G8bzUIvFdT0yk2vPAPnPyT0N2gLmGbAJouM3nSe7e+id2+iqdY+QCPvh0uA==}
|
||||||
|
engines: {node: '>=20.6.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@typescript/native-preview@7.0.0-dev.20250712.1':
|
||||||
|
resolution: {integrity: sha512-A8/aOsMpG6H8IcSIKYJSuHzbNkVr8dJOxbb4LMrSfOZ/JWayHQ4O5UJ9mSaKtyPwR6fInE5B8yMt7BYQOz77kA==}
|
||||||
|
engines: {node: '>=20.6.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.5.2':
|
'@vitejs/plugin-react@4.5.2':
|
||||||
resolution: {integrity: sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==}
|
resolution: {integrity: sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
@@ -3193,6 +3267,9 @@ packages:
|
|||||||
'@vitest/expect@3.2.3':
|
'@vitest/expect@3.2.3':
|
||||||
resolution: {integrity: sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==}
|
resolution: {integrity: sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==}
|
||||||
|
|
||||||
|
'@vitest/expect@3.2.4':
|
||||||
|
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
|
||||||
|
|
||||||
'@vitest/mocker@3.2.3':
|
'@vitest/mocker@3.2.3':
|
||||||
resolution: {integrity: sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==}
|
resolution: {integrity: sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3204,21 +3281,47 @@ packages:
|
|||||||
vite:
|
vite:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/mocker@3.2.4':
|
||||||
|
resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==}
|
||||||
|
peerDependencies:
|
||||||
|
msw: ^2.4.9
|
||||||
|
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
msw:
|
||||||
|
optional: true
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vitest/pretty-format@3.2.3':
|
'@vitest/pretty-format@3.2.3':
|
||||||
resolution: {integrity: sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==}
|
resolution: {integrity: sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==}
|
||||||
|
|
||||||
|
'@vitest/pretty-format@3.2.4':
|
||||||
|
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
|
||||||
|
|
||||||
'@vitest/runner@3.2.3':
|
'@vitest/runner@3.2.3':
|
||||||
resolution: {integrity: sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==}
|
resolution: {integrity: sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==}
|
||||||
|
|
||||||
|
'@vitest/runner@3.2.4':
|
||||||
|
resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==}
|
||||||
|
|
||||||
'@vitest/snapshot@3.2.3':
|
'@vitest/snapshot@3.2.3':
|
||||||
resolution: {integrity: sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==}
|
resolution: {integrity: sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==}
|
||||||
|
|
||||||
|
'@vitest/snapshot@3.2.4':
|
||||||
|
resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==}
|
||||||
|
|
||||||
'@vitest/spy@3.2.3':
|
'@vitest/spy@3.2.3':
|
||||||
resolution: {integrity: sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==}
|
resolution: {integrity: sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==}
|
||||||
|
|
||||||
|
'@vitest/spy@3.2.4':
|
||||||
|
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
|
||||||
|
|
||||||
'@vitest/utils@3.2.3':
|
'@vitest/utils@3.2.3':
|
||||||
resolution: {integrity: sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==}
|
resolution: {integrity: sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==}
|
||||||
|
|
||||||
|
'@vitest/utils@3.2.4':
|
||||||
|
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
||||||
|
|
||||||
'@webassemblyjs/ast@1.14.1':
|
'@webassemblyjs/ast@1.14.1':
|
||||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||||
|
|
||||||
@@ -4858,6 +4961,9 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jsonc-parser@3.3.1:
|
||||||
|
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||||
|
|
||||||
jsonfile@2.4.0:
|
jsonfile@2.4.0:
|
||||||
resolution: {integrity: sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw==}
|
resolution: {integrity: sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw==}
|
||||||
|
|
||||||
@@ -4984,6 +5090,9 @@ packages:
|
|||||||
loupe@3.1.3:
|
loupe@3.1.3:
|
||||||
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
|
resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==}
|
||||||
|
|
||||||
|
loupe@3.1.4:
|
||||||
|
resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==}
|
||||||
|
|
||||||
lower-case-first@2.0.2:
|
lower-case-first@2.0.2:
|
||||||
resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==}
|
resolution: {integrity: sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==}
|
||||||
|
|
||||||
@@ -5008,6 +5117,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 +5580,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'}
|
||||||
@@ -5925,6 +6043,9 @@ packages:
|
|||||||
simple-swizzle@0.2.2:
|
simple-swizzle@0.2.2:
|
||||||
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
|
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
|
||||||
|
|
||||||
|
sisteransi@1.0.5:
|
||||||
|
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||||
|
|
||||||
slash@3.0.0:
|
slash@3.0.0:
|
||||||
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -6162,6 +6283,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==}
|
||||||
|
|
||||||
@@ -6227,6 +6354,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==}
|
resolution: {integrity: sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
||||||
|
tinypool@1.1.1:
|
||||||
|
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
||||||
tinyrainbow@2.0.0:
|
tinyrainbow@2.0.0:
|
||||||
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
|
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -6351,8 +6482,8 @@ packages:
|
|||||||
uc.micro@2.1.0:
|
uc.micro@2.1.0:
|
||||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||||
|
|
||||||
ultracite@4.2.13:
|
ultracite@5.0.32:
|
||||||
resolution: {integrity: sha512-j49R1z3xXIPhdvU19x0z0Z4hNewJYn4F1h42ULeaCOylBuxwGVE401piPxe3aVapwue7+Ec3J6wnL/+mW4zwww==}
|
resolution: {integrity: sha512-JjVNswL1mkIaOkPVh1nuEGnbEaCa94+ftqJ9hpRX2Y+jt72pcv32JeWg3Dqhkz/e3l449f7KAzMHO4x6IbxFZQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
unbox-primitive@1.1.0:
|
unbox-primitive@1.1.0:
|
||||||
@@ -6459,6 +6590,11 @@ packages:
|
|||||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
vite-node@3.2.4:
|
||||||
|
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||||
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
vite@5.4.11:
|
vite@5.4.11:
|
||||||
resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==}
|
resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
@@ -6518,6 +6654,34 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vitest@3.2.4:
|
||||||
|
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
|
||||||
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@edge-runtime/vm': '*'
|
||||||
|
'@types/debug': ^4.1.12
|
||||||
|
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
|
||||||
|
'@vitest/browser': 3.2.4
|
||||||
|
'@vitest/ui': 3.2.4
|
||||||
|
happy-dom: '*'
|
||||||
|
jsdom: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@edge-runtime/vm':
|
||||||
|
optional: true
|
||||||
|
'@types/debug':
|
||||||
|
optional: true
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser':
|
||||||
|
optional: true
|
||||||
|
'@vitest/ui':
|
||||||
|
optional: true
|
||||||
|
happy-dom:
|
||||||
|
optional: true
|
||||||
|
jsdom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
vscode-languageserver-types@3.17.5:
|
vscode-languageserver-types@3.17.5:
|
||||||
resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==}
|
resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==}
|
||||||
|
|
||||||
@@ -7100,41 +7264,52 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.27.1
|
'@babel/helper-validator-identifier': 7.27.1
|
||||||
|
|
||||||
'@biomejs/biome@1.9.4':
|
'@biomejs/biome@2.1.1':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@biomejs/cli-darwin-arm64': 1.9.4
|
'@biomejs/cli-darwin-arm64': 2.1.1
|
||||||
'@biomejs/cli-darwin-x64': 1.9.4
|
'@biomejs/cli-darwin-x64': 2.1.1
|
||||||
'@biomejs/cli-linux-arm64': 1.9.4
|
'@biomejs/cli-linux-arm64': 2.1.1
|
||||||
'@biomejs/cli-linux-arm64-musl': 1.9.4
|
'@biomejs/cli-linux-arm64-musl': 2.1.1
|
||||||
'@biomejs/cli-linux-x64': 1.9.4
|
'@biomejs/cli-linux-x64': 2.1.1
|
||||||
'@biomejs/cli-linux-x64-musl': 1.9.4
|
'@biomejs/cli-linux-x64-musl': 2.1.1
|
||||||
'@biomejs/cli-win32-arm64': 1.9.4
|
'@biomejs/cli-win32-arm64': 2.1.1
|
||||||
'@biomejs/cli-win32-x64': 1.9.4
|
'@biomejs/cli-win32-x64': 2.1.1
|
||||||
|
|
||||||
'@biomejs/cli-darwin-arm64@1.9.4':
|
'@biomejs/cli-darwin-arm64@2.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-darwin-x64@1.9.4':
|
'@biomejs/cli-darwin-x64@2.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64-musl@1.9.4':
|
'@biomejs/cli-linux-arm64-musl@2.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-arm64@1.9.4':
|
'@biomejs/cli-linux-arm64@2.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64-musl@1.9.4':
|
'@biomejs/cli-linux-x64-musl@2.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-linux-x64@1.9.4':
|
'@biomejs/cli-linux-x64@2.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-arm64@1.9.4':
|
'@biomejs/cli-win32-arm64@2.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@biomejs/cli-win32-x64@1.9.4':
|
'@biomejs/cli-win32-x64@2.1.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@clack/core@0.5.0':
|
||||||
|
dependencies:
|
||||||
|
picocolors: 1.1.1
|
||||||
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
|
'@clack/prompts@0.11.0':
|
||||||
|
dependencies:
|
||||||
|
'@clack/core': 0.5.0
|
||||||
|
picocolors: 1.1.1
|
||||||
|
sisteransi: 1.0.5
|
||||||
|
|
||||||
'@codemirror/language@6.11.1':
|
'@codemirror/language@6.11.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/state': 6.5.2
|
'@codemirror/state': 6.5.2
|
||||||
@@ -7216,6 +7391,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 +9994,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
|
||||||
@@ -9836,6 +10020,37 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.0.10
|
'@types/node': 24.0.10
|
||||||
|
|
||||||
|
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20250712.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/native-preview-darwin-x64@7.0.0-dev.20250712.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/native-preview-linux-arm64@7.0.0-dev.20250712.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/native-preview-linux-arm@7.0.0-dev.20250712.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/native-preview-linux-x64@7.0.0-dev.20250712.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/native-preview-win32-arm64@7.0.0-dev.20250712.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/native-preview-win32-x64@7.0.0-dev.20250712.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@typescript/native-preview@7.0.0-dev.20250712.1':
|
||||||
|
optionalDependencies:
|
||||||
|
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20250712.1
|
||||||
|
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20250712.1
|
||||||
|
'@typescript/native-preview-linux-arm': 7.0.0-dev.20250712.1
|
||||||
|
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20250712.1
|
||||||
|
'@typescript/native-preview-linux-x64': 7.0.0-dev.20250712.1
|
||||||
|
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20250712.1
|
||||||
|
'@typescript/native-preview-win32-x64': 7.0.0-dev.20250712.1
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.5.2(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))':
|
'@vitejs/plugin-react@4.5.2(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.27.4
|
'@babel/core': 7.27.4
|
||||||
@@ -9856,6 +10071,14 @@ snapshots:
|
|||||||
chai: 5.2.0
|
chai: 5.2.0
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@vitest/expect@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/chai': 5.2.2
|
||||||
|
'@vitest/spy': 3.2.4
|
||||||
|
'@vitest/utils': 3.2.4
|
||||||
|
chai: 5.2.0
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@vitest/mocker@3.2.3(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))':
|
'@vitest/mocker@3.2.3(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 3.2.3
|
'@vitest/spy': 3.2.3
|
||||||
@@ -9864,32 +10087,66 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
|
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
|
||||||
|
|
||||||
|
'@vitest/mocker@3.2.4(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 3.2.4
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.17
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
|
||||||
|
|
||||||
'@vitest/pretty-format@3.2.3':
|
'@vitest/pretty-format@3.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@vitest/pretty-format@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@vitest/runner@3.2.3':
|
'@vitest/runner@3.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/utils': 3.2.3
|
'@vitest/utils': 3.2.3
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
strip-literal: 3.0.0
|
strip-literal: 3.0.0
|
||||||
|
|
||||||
|
'@vitest/runner@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/utils': 3.2.4
|
||||||
|
pathe: 2.0.3
|
||||||
|
strip-literal: 3.0.0
|
||||||
|
|
||||||
'@vitest/snapshot@3.2.3':
|
'@vitest/snapshot@3.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/pretty-format': 3.2.3
|
'@vitest/pretty-format': 3.2.3
|
||||||
magic-string: 0.30.17
|
magic-string: 0.30.17
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
'@vitest/snapshot@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 3.2.4
|
||||||
|
magic-string: 0.30.17
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
'@vitest/spy@3.2.3':
|
'@vitest/spy@3.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
tinyspy: 4.0.3
|
tinyspy: 4.0.3
|
||||||
|
|
||||||
|
'@vitest/spy@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
tinyspy: 4.0.3
|
||||||
|
|
||||||
'@vitest/utils@3.2.3':
|
'@vitest/utils@3.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/pretty-format': 3.2.3
|
'@vitest/pretty-format': 3.2.3
|
||||||
loupe: 3.1.3
|
loupe: 3.1.3
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@vitest/utils@3.2.4':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 3.2.4
|
||||||
|
loupe: 3.1.4
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@webassemblyjs/ast@1.14.1':
|
'@webassemblyjs/ast@1.14.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@webassemblyjs/helper-numbers': 1.13.2
|
'@webassemblyjs/helper-numbers': 1.13.2
|
||||||
@@ -10219,7 +10476,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 +10539,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:
|
||||||
@@ -11749,6 +12005,8 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
jsonc-parser@3.3.1: {}
|
||||||
|
|
||||||
jsonfile@2.4.0:
|
jsonfile@2.4.0:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -11866,6 +12124,8 @@ snapshots:
|
|||||||
|
|
||||||
loupe@3.1.3: {}
|
loupe@3.1.3: {}
|
||||||
|
|
||||||
|
loupe@3.1.4: {}
|
||||||
|
|
||||||
lower-case-first@2.0.2:
|
lower-case-first@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -11892,6 +12152,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 +12579,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: {}
|
||||||
@@ -12894,6 +13162,8 @@ snapshots:
|
|||||||
is-arrayish: 0.3.2
|
is-arrayish: 0.3.2
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
sisteransi@1.0.5: {}
|
||||||
|
|
||||||
slash@3.0.0: {}
|
slash@3.0.0: {}
|
||||||
|
|
||||||
slice-ansi@3.0.0:
|
slice-ansi@3.0.0:
|
||||||
@@ -13154,6 +13424,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: {}
|
||||||
@@ -13208,6 +13485,8 @@ snapshots:
|
|||||||
|
|
||||||
tinypool@1.1.0: {}
|
tinypool@1.1.0: {}
|
||||||
|
|
||||||
|
tinypool@1.1.1: {}
|
||||||
|
|
||||||
tinyrainbow@2.0.0: {}
|
tinyrainbow@2.0.0: {}
|
||||||
|
|
||||||
tinyspy@4.0.3: {}
|
tinyspy@4.0.3: {}
|
||||||
@@ -13334,9 +13613,30 @@ snapshots:
|
|||||||
|
|
||||||
uc.micro@2.1.0: {}
|
uc.micro@2.1.0: {}
|
||||||
|
|
||||||
ultracite@4.2.13:
|
ultracite@5.0.32(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@clack/prompts': 0.11.0
|
||||||
commander: 14.0.0
|
commander: 14.0.0
|
||||||
|
deepmerge: 4.3.1
|
||||||
|
jsonc-parser: 3.3.1
|
||||||
|
vitest: 3.2.4(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@edge-runtime/vm'
|
||||||
|
- '@types/debug'
|
||||||
|
- '@types/node'
|
||||||
|
- '@vitest/browser'
|
||||||
|
- '@vitest/ui'
|
||||||
|
- happy-dom
|
||||||
|
- jsdom
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- msw
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
unbox-primitive@1.1.0:
|
unbox-primitive@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -13471,6 +13771,24 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
|
vite-node@3.2.4(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
|
||||||
|
dependencies:
|
||||||
|
cac: 6.7.14
|
||||||
|
debug: 4.4.1
|
||||||
|
es-module-lexer: 1.7.0
|
||||||
|
pathe: 2.0.3
|
||||||
|
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/node'
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
|
vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
@@ -13522,6 +13840,45 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
|
vitest@3.2.4(@types/node@24.0.10)(jsdom@25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5))(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/chai': 5.2.2
|
||||||
|
'@vitest/expect': 3.2.4
|
||||||
|
'@vitest/mocker': 3.2.4(vite@5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1))
|
||||||
|
'@vitest/pretty-format': 3.2.4
|
||||||
|
'@vitest/runner': 3.2.4
|
||||||
|
'@vitest/snapshot': 3.2.4
|
||||||
|
'@vitest/spy': 3.2.4
|
||||||
|
'@vitest/utils': 3.2.4
|
||||||
|
chai: 5.2.0
|
||||||
|
debug: 4.4.1
|
||||||
|
expect-type: 1.2.1
|
||||||
|
magic-string: 0.30.17
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.2
|
||||||
|
std-env: 3.9.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 0.3.2
|
||||||
|
tinyglobby: 0.2.14
|
||||||
|
tinypool: 1.1.1
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
vite: 5.4.11(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
|
||||||
|
vite-node: 3.2.4(@types/node@24.0.10)(lightningcss@1.30.1)(sass@1.77.4)(terser@5.43.1)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 24.0.10
|
||||||
|
jsdom: 25.0.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- msw
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
vscode-languageserver-types@3.17.5: {}
|
vscode-languageserver-types@3.17.5: {}
|
||||||
|
|
||||||
w3c-keyname@2.2.8: {}
|
w3c-keyname@2.2.8: {}
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user