Compare commits
8
Commits
3c1a603b90
...
d7a6e17531
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7a6e17531 | ||
|
|
c7a66c22b6 | ||
|
|
608872bf06 | ||
|
|
d3f0f57f6d | ||
|
|
b7ded69a7d | ||
|
|
b2ff255e32 | ||
|
|
369fc07b62 | ||
|
|
69d721055d |
+4
-1
@@ -110,7 +110,10 @@ pub async fn preview_cron(
|
|||||||
chrono::Utc::now(),
|
chrono::Utc::now(),
|
||||||
request.count,
|
request.count,
|
||||||
)
|
)
|
||||||
.map_err(|error| ApiError::validation("expression", error.to_string()))?;
|
.map_err(|error| {
|
||||||
|
tracing::debug!(error = %error, "cron preview failed");
|
||||||
|
ApiError::validation("expression", error.user_message())
|
||||||
|
})?;
|
||||||
Ok(Json(preview))
|
Ok(Json(preview))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,27 @@ pub enum CronPreviewError {
|
|||||||
InvalidTimezone(String),
|
InvalidTimezone(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl CronPreviewError {
|
||||||
|
/// 面向界面的中文说明。解析器原始错误(英文、带组件路径)只写日志,
|
||||||
|
/// 不再直接抛给前端。
|
||||||
|
pub fn user_message(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::InvalidExpression(detail) => {
|
||||||
|
if detail.contains("Invalid step syntax") {
|
||||||
|
"Cron 表达式步长写法不正确:不要用「0/2」这种单数字步长,通配步长写「*/2」,范围步长写「1-10/2」"
|
||||||
|
.to_owned()
|
||||||
|
} else {
|
||||||
|
"Cron 表达式无效,请检查字段取值与步长写法(通配步长用 */n,范围步长用 a-b/n)"
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::TooManyRuns => format!("一次最多预览 {MAX_PREVIEW_RUNS} 次运行"),
|
||||||
|
Self::NoFutureOccurrence => "Cron 表达式没有可计算的未来执行时间".to_owned(),
|
||||||
|
Self::InvalidTimezone(_) => "时区必须是有效的 IANA 时区名称".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, ToSchema)]
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CronPreviewRequest {
|
pub struct CronPreviewRequest {
|
||||||
|
|||||||
@@ -166,14 +166,15 @@ fn validate_common(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let cron = Cron::from_str(cron_expression).map_err(|error| {
|
let cron = Cron::from_str(cron_expression).map_err(|error| {
|
||||||
crate::error::ApiError::validation("cron", format!("Cron 表达式无效: {error}"))
|
tracing::debug!(error = %error, "invalid cron expression");
|
||||||
|
let message =
|
||||||
|
crate::cron::CronPreviewError::InvalidExpression(error.to_string()).user_message();
|
||||||
|
crate::error::ApiError::validation("cron", message)
|
||||||
})?;
|
})?;
|
||||||
cron.find_next_occurrence(&Utc::now(), false)
|
cron.find_next_occurrence(&Utc::now(), false)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
crate::error::ApiError::validation(
|
tracing::debug!(error = %error, "cron has no future occurrence");
|
||||||
"cron",
|
crate::error::ApiError::validation("cron", "Cron 表达式没有可计算的未来执行时间")
|
||||||
format!("Cron 表达式没有可计算的未来执行时间: {error}"),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -142,6 +142,13 @@ test('manages a task and reads a real run log', async ({ page, request }) => {
|
|||||||
})
|
})
|
||||||
await drawer.getByRole('button', { name: '关闭' }).click()
|
await drawer.getByRole('button', { name: '关闭' }).click()
|
||||||
|
|
||||||
|
// 运行已结束时再打开日志,也要能回放(曾因 rAF 句柄未归零停在空态)。
|
||||||
|
await page.getByRole('button', { name: '日志' }).click()
|
||||||
|
const replayDrawer = page.getByRole('dialog', { name: 'E2E echo' })
|
||||||
|
await expect(replayDrawer).toBeVisible()
|
||||||
|
await expect(replayDrawer.getByText('e2e-output')).toBeVisible()
|
||||||
|
await replayDrawer.getByRole('button', { name: '关闭' }).click()
|
||||||
|
|
||||||
await page.getByRole('button', { name: '编辑' }).click()
|
await page.getByRole('button', { name: '编辑' }).click()
|
||||||
const editor = page.getByRole('dialog', {
|
const editor = page.getByRole('dialog', {
|
||||||
name: '编辑任务 [e2e_echo]',
|
name: '编辑任务 [e2e_echo]',
|
||||||
|
|||||||
+86
-10
@@ -77,7 +77,8 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 12px 16px;
|
/* 搜索框贴卡片左边缘,和页头 / 指标卡对齐;右侧给筛选留 16px 呼吸位 */
|
||||||
|
padding: 12px 16px 12px 0;
|
||||||
border-bottom: 1px solid var(--su-border);
|
border-bottom: 1px solid var(--su-border);
|
||||||
background-color: var(--su-bg-subtle);
|
background-color: var(--su-bg-subtle);
|
||||||
}
|
}
|
||||||
@@ -105,9 +106,16 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-name-row {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.task-name {
|
.task-name {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 240px;
|
max-width: 170px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -118,6 +126,10 @@
|
|||||||
font-family: var(--su-font-mono);
|
font-family: var(--su-font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timezone-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-cell {
|
.schedule-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -193,6 +205,21 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 弹窗内容仍可用滚轮 / 触控板滚动,但不显示右侧滚动条 */
|
||||||
|
.task-editor-modal > div,
|
||||||
|
.directory-picker-modal > div,
|
||||||
|
.directory-picker-body {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-editor-modal > div::-webkit-scrollbar,
|
||||||
|
.directory-picker-modal > div::-webkit-scrollbar,
|
||||||
|
.directory-picker-body::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.task-editor-modal > header,
|
.task-editor-modal > header,
|
||||||
.task-editor-modal > footer {
|
.task-editor-modal > footer {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -210,8 +237,9 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid-single {
|
/* 虚拟环境目录通常是一条完整路径,给它更多宽度;解释器名很短 */
|
||||||
grid-template-columns: 1fr;
|
.venv-grid {
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.9fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-select {
|
.form-select {
|
||||||
@@ -235,15 +263,13 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cron-preview-box {
|
.cron-preview-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-height: 160px;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 10px;
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--su-border);
|
border: 1px solid var(--su-border);
|
||||||
border-radius: var(--su-radius-sm);
|
border-radius: var(--su-radius-sm);
|
||||||
padding: 10px 12px;
|
padding: 12px 14px;
|
||||||
background-color: var(--su-bg-subtle);
|
background-color: var(--su-bg-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,15 +277,42 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-preview-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-preview-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-preview-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cron-count-select {
|
.cron-count-select {
|
||||||
min-width: 96px;
|
min-width: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 未来运行时间按三列铺开:默认 5 条一眼看完,不需要内部滚动条 */
|
||||||
|
.cron-run-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.cron-run-item {
|
.cron-run-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: var(--su-text-secondary);
|
color: var(--su-text-secondary);
|
||||||
@@ -269,22 +322,36 @@
|
|||||||
|
|
||||||
.cron-run-index {
|
.cron-run-index {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
|
flex: none;
|
||||||
color: var(--su-text-muted);
|
color: var(--su-text-muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cron-run-time {
|
.cron-run-time {
|
||||||
|
overflow: hidden;
|
||||||
border: 1px solid var(--su-border);
|
border: 1px solid var(--su-border);
|
||||||
border-radius: var(--su-radius-xs);
|
border-radius: var(--su-radius-xs);
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
background-color: var(--su-bg-card);
|
background-color: var(--su-bg-card);
|
||||||
color: var(--su-text-main);
|
color: var(--su-text-main);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cron-preview-error {
|
.cron-preview-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
color: var(--su-color-danger);
|
color: var(--su-color-danger);
|
||||||
|
font-size: var(--su-font-size-sm);
|
||||||
|
line-height: var(--su-line-height-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-preview-error i {
|
||||||
|
flex: none;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.env-detector-bar {
|
.env-detector-bar {
|
||||||
@@ -832,6 +899,7 @@
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-search {
|
.toolbar-search {
|
||||||
@@ -852,6 +920,14 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.venv-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-run-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.argument-row,
|
.argument-row,
|
||||||
.key-value-row {
|
.key-value-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -409,6 +409,10 @@ function RunLogOutput({
|
|||||||
return () => {
|
return () => {
|
||||||
if (frameRef.current) {
|
if (frameRef.current) {
|
||||||
cancelAnimationFrame(frameRef.current)
|
cancelAnimationFrame(frameRef.current)
|
||||||
|
// StrictMode 会「挂载 → 清理 → 再挂载」,取消后必须把句柄归零,
|
||||||
|
// 否则下一次 scheduleRender 会误以为还有帧在排队而不再安排渲染,
|
||||||
|
// 已结束的 run(complete 在首帧就为 true)会一直停在「等待日志输出」。
|
||||||
|
frameRef.current = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
FormField,
|
FormField,
|
||||||
|
Icon,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Notice,
|
Notice,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
Select,
|
Select,
|
||||||
|
Spinner,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextButton,
|
TextButton,
|
||||||
} from '@stash/ui'
|
} from '@stash/ui'
|
||||||
import { useEffect, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
type FieldPath,
|
type FieldPath,
|
||||||
@@ -58,13 +60,7 @@ const HTTP_METHOD_OPTIONS = [
|
|||||||
{ value: 'POST', label: 'POST' },
|
{ value: 'POST', label: 'POST' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const TIMEZONE_SUGGESTIONS = [
|
const TIMEZONE_OPTIONS = ['Asia/Shanghai']
|
||||||
'Asia/Shanghai',
|
|
||||||
'Asia/Hong_Kong',
|
|
||||||
'UTC',
|
|
||||||
'Europe/London',
|
|
||||||
'America/New_York',
|
|
||||||
]
|
|
||||||
|
|
||||||
const CRON_PREVIEW_COUNTS = [3, 5, 8, 10]
|
const CRON_PREVIEW_COUNTS = [3, 5, 8, 10]
|
||||||
|
|
||||||
@@ -85,9 +81,13 @@ export function TaskEditorModal({
|
|||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
const [cronPreviewCount, setCronPreviewCount] = useState(5)
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
// 记录「自动填入」的虚拟环境目录:用户手动改过就不再覆盖;
|
||||||
|
// 工作目录变化时只有仍等于该值的字段会被清掉,交给新目录重新探测。
|
||||||
|
const autoVenvRef = useRef('')
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
control,
|
control,
|
||||||
|
getValues,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
watch,
|
watch,
|
||||||
@@ -97,12 +97,20 @@ export function TaskEditorModal({
|
|||||||
} = useForm<TaskFormValues>({
|
} = useForm<TaskFormValues>({
|
||||||
resolver: zodResolver(taskFormSchema),
|
resolver: zodResolver(taskFormSchema),
|
||||||
defaultValues: createEmptyTaskFormValues(),
|
defaultValues: createEmptyTaskFormValues(),
|
||||||
mode: 'onBlur',
|
// 长表单在 blur 时插错误行会让下面所有控件瞬间位移,刚点下去的按钮
|
||||||
|
// 可能在 mouseup 前被挪走(下拉框会「点了没反应」)。改为提交时校验,
|
||||||
|
// 提交后仍按 onChange 实时修正。
|
||||||
|
mode: 'onSubmit',
|
||||||
})
|
})
|
||||||
const actionType = watch('actionType')
|
const actionType = watch('actionType')
|
||||||
const cronValue = watch('cron')
|
const cronValue = watch('cron')
|
||||||
const timezoneValue = watch('timezone')
|
const timezoneValue = watch('timezone')
|
||||||
const scriptPreview = watch('script')
|
const scriptPreview = watch('script')
|
||||||
|
// 当前只开放 Asia/Shanghai;编辑旧任务时把它已有的时区也保留成一个选项,
|
||||||
|
// 避免下拉显示成空值。
|
||||||
|
const timezoneOptions = Array.from(
|
||||||
|
new Set(timezoneValue ? [...TIMEZONE_OPTIONS, timezoneValue] : TIMEZONE_OPTIONS),
|
||||||
|
).map((zone) => ({ value: zone, label: zone }))
|
||||||
const debouncedCron = useDebouncedValue(cronValue, 300)
|
const debouncedCron = useDebouncedValue(cronValue, 300)
|
||||||
const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
|
const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
|
||||||
const debouncedWorkingDir = useDebouncedValue(
|
const debouncedWorkingDir = useDebouncedValue(
|
||||||
@@ -176,8 +184,49 @@ export function TaskEditorModal({
|
|||||||
setCronPreviewCount(5)
|
setCronPreviewCount(5)
|
||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
setPickerOpen(false)
|
setPickerOpen(false)
|
||||||
|
autoVenvRef.current = ''
|
||||||
}, [open, reset, task])
|
}, [open, reset, task])
|
||||||
|
|
||||||
|
// 换工作目录时清掉上一轮自动填入的值(手动填写的值不动)。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (autoVenvRef.current && getValues('script.venv').trim() === autoVenvRef.current) {
|
||||||
|
setValue('script.venv', '', { shouldDirty: false })
|
||||||
|
}
|
||||||
|
autoVenvRef.current = ''
|
||||||
|
}, [debouncedWorkingDir, getValues, open, setValue])
|
||||||
|
|
||||||
|
// 探测到虚拟环境就自动填进「虚拟环境目录」,但用户自己改过就保留用户的值。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const probe = venvProbe.data
|
||||||
|
if (!probe || probe.status !== 'linked' || !probe.venv_path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = getValues('script.venv').trim()
|
||||||
|
// 防抖中的输入可能还没落到查询键上:只有「当前输入」与「本次探测用的输入」
|
||||||
|
// 一致时才回填,否则工作目录刚变化时可能把上一轮探测结果又填回去。
|
||||||
|
if (debouncedVenv.trim() !== current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (current && current !== autoVenvRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (current !== '' && current === autoVenvRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = venvFieldValue(debouncedWorkingDir, probe.venv_path)
|
||||||
|
autoVenvRef.current = next
|
||||||
|
setValue('script.venv', next, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}, [debouncedVenv, debouncedWorkingDir, getValues, open, setValue, venvProbe.data])
|
||||||
|
|
||||||
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。
|
// 打开后把焦点给第一个可编辑字段:新建时是任务标识,编辑时 ID 已锁定,跳名称。
|
||||||
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
|
// 原生 dialog 默认聚焦关闭按钮,不符合编辑表单的节奏。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -286,44 +335,8 @@ export function TaskEditorModal({
|
|||||||
{...register('cron')}
|
{...register('cron')}
|
||||||
className="mono-input"
|
className="mono-input"
|
||||||
placeholder="0 0 2 * * *"
|
placeholder="0 0 2 * * *"
|
||||||
aria-invalid={Boolean(errors.cron)}
|
aria-invalid={Boolean(errors.cron) || cronPreview.isError}
|
||||||
/>
|
/>
|
||||||
<div className="cron-preview-box" aria-live="polite">
|
|
||||||
<div className="cron-preview-head">
|
|
||||||
<Text size="xs" tone="muted">
|
|
||||||
预览未来运行时间
|
|
||||||
</Text>
|
|
||||||
<Select
|
|
||||||
className="cron-count-select"
|
|
||||||
options={CRON_PREVIEW_COUNTS.map((count) => ({
|
|
||||||
value: String(count),
|
|
||||||
label: `${count} 次`,
|
|
||||||
}))}
|
|
||||||
value={String(cronPreviewCount)}
|
|
||||||
onChange={(value) => setCronPreviewCount(Number(value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{cronPreview.isFetching ? (
|
|
||||||
<Text size="sm" tone="sub">
|
|
||||||
正在计算...
|
|
||||||
</Text>
|
|
||||||
) : cronPreview.isError ? (
|
|
||||||
<Text size="sm" className="cron-preview-error">
|
|
||||||
{cronPreview.error.message}
|
|
||||||
</Text>
|
|
||||||
) : cronPreview.data?.runs.length ? (
|
|
||||||
cronPreview.data.runs.map((run, index) => (
|
|
||||||
<div className="cron-run-item" key={run}>
|
|
||||||
<span className="cron-run-index">#{index + 1}</span>
|
|
||||||
<span className="cron-run-time">{run}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Text size="sm" tone="sub">
|
|
||||||
等待输入有效 Cron 表达式...
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@@ -348,24 +361,82 @@ export function TaskEditorModal({
|
|||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-grid form-grid-single">
|
<section
|
||||||
<FormField
|
className="cron-preview-panel"
|
||||||
label="IANA 时区"
|
aria-label="未来运行时间"
|
||||||
htmlFor="task-timezone"
|
aria-live="polite"
|
||||||
error={errors.timezone?.message}
|
|
||||||
>
|
>
|
||||||
<Input
|
<div className="cron-preview-head">
|
||||||
id="task-timezone"
|
<div className="cron-preview-title">
|
||||||
{...register('timezone')}
|
<Text strong size="sm">
|
||||||
list="timezone-options"
|
未来运行时间
|
||||||
placeholder="Asia/Shanghai"
|
</Text>
|
||||||
aria-invalid={Boolean(errors.timezone)}
|
<Text size="xs" tone="muted">
|
||||||
|
按当前 Cron 与时区实时计算
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div className="cron-preview-actions">
|
||||||
|
<Text size="xs" tone="muted">
|
||||||
|
预览
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
className="cron-count-select"
|
||||||
|
options={CRON_PREVIEW_COUNTS.map((count) => ({
|
||||||
|
value: String(count),
|
||||||
|
label: `${count} 次`,
|
||||||
|
}))}
|
||||||
|
value={String(cronPreviewCount)}
|
||||||
|
onChange={(value) => setCronPreviewCount(Number(value))}
|
||||||
/>
|
/>
|
||||||
<datalist id="timezone-options">
|
</div>
|
||||||
{TIMEZONE_SUGGESTIONS.map((zone) => (
|
</div>
|
||||||
<option value={zone} key={zone} />
|
|
||||||
|
{cronPreview.isFetching ? (
|
||||||
|
<div className="cron-preview-status">
|
||||||
|
<Spinner size="sm" label="正在计算" />
|
||||||
|
<Text size="sm" tone="sub">
|
||||||
|
正在计算...
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
) : cronPreview.isError ? (
|
||||||
|
<div className="cron-preview-error">
|
||||||
|
<Icon name="warning_line" />
|
||||||
|
<span>
|
||||||
|
{cronPreview.error instanceof ApiRequestError
|
||||||
|
? cronPreview.error.message
|
||||||
|
: '暂时无法计算运行时间,请稍后重试'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : cronPreview.data?.runs.length ? (
|
||||||
|
<div className="cron-run-grid">
|
||||||
|
{cronPreview.data.runs.map((run, index) => (
|
||||||
|
<div className="cron-run-item" key={run}>
|
||||||
|
<span className="cron-run-index">#{index + 1}</span>
|
||||||
|
<span className="cron-run-time">{run}</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</datalist>
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" tone="sub">
|
||||||
|
等待输入有效 Cron 表达式...
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="form-grid">
|
||||||
|
<FormField label="IANA 时区" error={errors.timezone?.message}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="timezone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
className="form-select"
|
||||||
|
options={timezoneOptions}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<FormField label="任务类型 (Action Type)">
|
<FormField label="任务类型 (Action Type)">
|
||||||
@@ -411,14 +482,20 @@ export function TaskEditorModal({
|
|||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
<VenvDetectorBar
|
<div className="form-grid venv-grid">
|
||||||
workingDir={debouncedWorkingDir}
|
<FormField
|
||||||
probe={venvProbe.data}
|
label="虚拟环境目录 (可选)"
|
||||||
pending={venvProbe.isPending}
|
htmlFor="task-venv"
|
||||||
failed={venvProbe.isError}
|
hint="检测到虚拟环境会自动填入,可直接修改;留空则按工作目录自动探测"
|
||||||
|
error={errors.script?.venv?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="task-venv"
|
||||||
|
{...register('script.venv')}
|
||||||
|
className="mono-input"
|
||||||
|
placeholder="例如:.venv"
|
||||||
/>
|
/>
|
||||||
|
</FormField>
|
||||||
<div className="form-grid">
|
|
||||||
<FormField
|
<FormField
|
||||||
label="解释器"
|
label="解释器"
|
||||||
htmlFor="task-interpreter"
|
htmlFor="task-interpreter"
|
||||||
@@ -431,21 +508,15 @@ export function TaskEditorModal({
|
|||||||
placeholder="python.exe"
|
placeholder="python.exe"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField
|
|
||||||
label="虚拟环境目录 (可选)"
|
|
||||||
htmlFor="task-venv"
|
|
||||||
hint="留空时自动探测;填写后优先使用该目录"
|
|
||||||
error={errors.script?.venv?.message}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="task-venv"
|
|
||||||
{...register('script.venv')}
|
|
||||||
className="mono-input"
|
|
||||||
placeholder="例如:.venv"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VenvDetectorBar
|
||||||
|
workingDir={debouncedWorkingDir}
|
||||||
|
probe={venvProbe.data}
|
||||||
|
pending={venvProbe.isPending}
|
||||||
|
failed={venvProbe.isError}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="脚本文件或相对路径"
|
label="脚本文件或相对路径"
|
||||||
htmlFor="task-script"
|
htmlFor="task-script"
|
||||||
@@ -755,6 +826,30 @@ function formatArgumentPreview(argumentsList: KeyValueFormValue[]): string {
|
|||||||
return values.length ? ` ${values.join(' ')}` : ''
|
return values.length ? ` ${values.join(' ')}` : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 探测结果给的是绝对路径;虚拟环境位于工作目录内时写成相对路径,
|
||||||
|
* 任务搬家后仍然可用(与手填 `.venv` 的语义一致)。
|
||||||
|
*/
|
||||||
|
function venvFieldValue(workingDir: string, venvPath: string): string {
|
||||||
|
const base = workingDir.trim().replace(/[\\/]+$/, '')
|
||||||
|
const path = venvPath.trim()
|
||||||
|
if (!base || !path) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
const normalizedBase = base.toLowerCase()
|
||||||
|
const normalizedPath = path.toLowerCase()
|
||||||
|
if (normalizedPath === normalizedBase) {
|
||||||
|
return '.'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
normalizedPath.startsWith(`${normalizedBase}\\`) ||
|
||||||
|
normalizedPath.startsWith(`${normalizedBase}/`)
|
||||||
|
) {
|
||||||
|
return path.slice(base.length + 1).replace(/\//g, '\\')
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
function formatInterpreterPreview(
|
function formatInterpreterPreview(
|
||||||
script: TaskFormValues['script'],
|
script: TaskFormValues['script'],
|
||||||
linkedPythonPath?: string | null,
|
linkedPythonPath?: string | null,
|
||||||
|
|||||||
@@ -71,7 +71,13 @@ textarea {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 组件库的「无底色按钮」依赖宿主这份 reset(Select 选项、Segmented、Tabs、Menu
|
||||||
|
都只声明选中态底色)。少了它,浏览器默认的 ButtonFace 灰底和按钮边框会露出来,
|
||||||
|
下拉选项会变成一块块灰条。 */
|
||||||
button {
|
button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ const ACTION_TYPE_OPTIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const TABLE_COLUMNS: TableColumn[] = [
|
const TABLE_COLUMNS: TableColumn[] = [
|
||||||
{ key: 'name', title: '名称 / 任务标识', width: 260 },
|
{ key: 'name', title: '名称 / 任务标识', width: 250 },
|
||||||
{ key: 'schedule', title: '调度周期', width: 240 },
|
{ key: 'timezone', title: '时区', width: 120 },
|
||||||
{ key: 'type', title: '类型', width: 96 },
|
{ key: 'schedule', title: '调度周期', width: 210 },
|
||||||
{ key: 'status', title: '状态', width: 120 },
|
{ key: 'status', title: '状态', width: 110 },
|
||||||
{ key: 'timeout', title: '超时配置', width: 100 },
|
{ key: 'timeout', title: '超时', width: 90 },
|
||||||
{ key: 'actions', title: '操作', align: 'right', width: 300 },
|
{ key: 'actions', title: '操作', align: 'right', width: 260 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function TaskDashboard() {
|
export function TaskDashboard() {
|
||||||
@@ -89,6 +89,7 @@ export function TaskDashboard() {
|
|||||||
const [logTask, setLogTask] = useState<Task | null>(null)
|
const [logTask, setLogTask] = useState<Task | null>(null)
|
||||||
const [initialRunId, setInitialRunId] = useState<number | null>(null)
|
const [initialRunId, setInitialRunId] = useState<number | null>(null)
|
||||||
const [actionError, setActionError] = useState<string | null>(null)
|
const [actionError, setActionError] = useState<string | null>(null)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [queryDraft, setQueryDraft] = useState(q)
|
const [queryDraft, setQueryDraft] = useState(q)
|
||||||
const debouncedQuery = useDebouncedValue(queryDraft, 300)
|
const debouncedQuery = useDebouncedValue(queryDraft, 300)
|
||||||
|
|
||||||
@@ -229,9 +230,19 @@ export function TaskDashboard() {
|
|||||||
setEditorOpen(true)
|
setEditorOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshAll = () => {
|
const refreshAll = async () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
if (refreshing) {
|
||||||
void queryClient.invalidateQueries({ queryKey: ['health'] })
|
return
|
||||||
|
}
|
||||||
|
setRefreshing(true)
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['health'] }),
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = tasks.map((task) => {
|
const rows = tasks.map((task) => {
|
||||||
@@ -244,9 +255,12 @@ export function TaskDashboard() {
|
|||||||
id: task.id,
|
id: task.id,
|
||||||
name: (
|
name: (
|
||||||
<div className="task-name-cell">
|
<div className="task-name-cell">
|
||||||
|
<div className="task-name-row">
|
||||||
<Text className="task-name" strong>
|
<Text className="task-name" strong>
|
||||||
{task.name}
|
{task.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
|
||||||
|
</div>
|
||||||
<Text className="task-id" size="xs" tone="muted">
|
<Text className="task-id" size="xs" tone="muted">
|
||||||
{task.id}
|
{task.id}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -255,9 +269,6 @@ export function TaskDashboard() {
|
|||||||
schedule: (
|
schedule: (
|
||||||
<div className="schedule-cell">
|
<div className="schedule-cell">
|
||||||
<code className="code-chip">{task.cron}</code>
|
<code className="code-chip">{task.cron}</code>
|
||||||
<Text size="xs" tone="muted">
|
|
||||||
{task.timezone}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" tone="muted">
|
<Text size="xs" tone="muted">
|
||||||
{task.enabled
|
{task.enabled
|
||||||
? nextRuns.get(task.id)
|
? nextRuns.get(task.id)
|
||||||
@@ -267,8 +278,10 @@ export function TaskDashboard() {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
type: (
|
timezone: (
|
||||||
<Tag shape="rounded">{actionTypeLabel(task.action.type)}</Tag>
|
<Text className="timezone-cell" size="sm" tone="sub">
|
||||||
|
{task.timezone}
|
||||||
|
</Text>
|
||||||
),
|
),
|
||||||
status: <EnabledBadge enabled={task.enabled} />,
|
status: <EnabledBadge enabled={task.enabled} />,
|
||||||
timeout: (
|
timeout: (
|
||||||
@@ -403,8 +416,11 @@ export function TaskDashboard() {
|
|||||||
清除筛选
|
清除筛选
|
||||||
</TextButton>
|
</TextButton>
|
||||||
) : null}
|
) : null}
|
||||||
<TextButton onClick={refreshAll} disabled={tasksQuery.isFetching}>
|
<TextButton
|
||||||
{tasksQuery.isFetching ? '同步中...' : '刷新'}
|
onClick={() => void refreshAll()}
|
||||||
|
disabled={refreshing}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
</TextButton>
|
</TextButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user