feat(desktop): add Cron Jobs management page
- Add "Cron" tab to navigation bar with Time04Icon - Add /crons route with CronsPage component - Add cron IPC handlers (list, toggle, remove) in electron/ipc/cron.ts - Expose cron API in preload.ts for renderer process - Add useCronJobs hook for fetching and managing jobs - Add CronJobList component with status badges, toggle switches, delete buttons, relative time display, and empty state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9bd18472b8
commit
a8dd9f2bbb
8 changed files with 447 additions and 0 deletions
68
apps/desktop/electron/ipc/cron.ts
Normal file
68
apps/desktop/electron/ipc/cron.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Cron IPC handlers for Electron main process.
|
||||
*
|
||||
* These handlers expose CronService operations to the renderer process
|
||||
* for the Cron Jobs management page.
|
||||
*/
|
||||
import { ipcMain } from 'electron'
|
||||
import { getCronService, formatSchedule } from '../../../../src/cron/index.js'
|
||||
|
||||
/**
|
||||
* Register all Cron-related IPC handlers.
|
||||
*/
|
||||
export function registerCronIpcHandlers(): void {
|
||||
/**
|
||||
* List all cron jobs with formatted display fields.
|
||||
*/
|
||||
ipcMain.handle('cron:list', async () => {
|
||||
const service = getCronService()
|
||||
const jobs = service.list()
|
||||
|
||||
return jobs.map((job) => ({
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
description: job.description,
|
||||
enabled: job.enabled,
|
||||
schedule: formatSchedule(job.schedule),
|
||||
sessionTarget: job.sessionTarget,
|
||||
nextRunAt: job.state.nextRunAtMs ? new Date(job.state.nextRunAtMs).toISOString() : null,
|
||||
lastStatus: job.state.lastStatus ?? null,
|
||||
lastRunAt: job.state.lastRunAtMs ? new Date(job.state.lastRunAtMs).toISOString() : null,
|
||||
lastDurationMs: job.state.lastDurationMs ?? null,
|
||||
lastError: job.state.lastError ?? null,
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* Toggle a cron job's enabled status.
|
||||
*/
|
||||
ipcMain.handle('cron:toggle', async (_event, jobId: string) => {
|
||||
const service = getCronService()
|
||||
const job = service.get(jobId)
|
||||
if (!job) {
|
||||
return { error: `Job not found: ${jobId}` }
|
||||
}
|
||||
|
||||
const updated = service.update(jobId, { enabled: !job.enabled })
|
||||
if (!updated) {
|
||||
return { error: `Failed to update job: ${jobId}` }
|
||||
}
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
enabled: updated.enabled,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Remove a cron job.
|
||||
*/
|
||||
ipcMain.handle('cron:remove', async (_event, jobId: string) => {
|
||||
const service = getCronService()
|
||||
const removed = service.remove(jobId)
|
||||
if (!removed) {
|
||||
return { error: `Job not found: ${jobId}` }
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
|
|
@ -6,12 +6,14 @@ export { registerSkillsIpcHandlers } from './skills.js'
|
|||
export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js'
|
||||
export { registerProfileIpcHandlers } from './profile.js'
|
||||
export { registerProviderIpcHandlers } from './provider.js'
|
||||
export { registerCronIpcHandlers } from './cron.js'
|
||||
|
||||
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
|
||||
import { registerSkillsIpcHandlers } from './skills.js'
|
||||
import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js'
|
||||
import { registerProfileIpcHandlers } from './profile.js'
|
||||
import { registerProviderIpcHandlers } from './provider.js'
|
||||
import { registerCronIpcHandlers } from './cron.js'
|
||||
|
||||
/**
|
||||
* Register all IPC handlers.
|
||||
|
|
@ -23,6 +25,7 @@ export function registerAllIpcHandlers(): void {
|
|||
registerSkillsIpcHandlers()
|
||||
registerProfileIpcHandlers()
|
||||
registerProviderIpcHandlers()
|
||||
registerCronIpcHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -201,6 +201,13 @@ const electronAPI = {
|
|||
ipcRenderer.invoke('provider:importOAuth', providerId),
|
||||
},
|
||||
|
||||
// Cron jobs management
|
||||
cron: {
|
||||
list: () => ipcRenderer.invoke('cron:list'),
|
||||
toggle: (jobId: string) => ipcRenderer.invoke('cron:toggle', jobId),
|
||||
remove: (jobId: string) => ipcRenderer.invoke('cron:remove', jobId),
|
||||
},
|
||||
|
||||
// Local chat (direct IPC, no Gateway required)
|
||||
localChat: {
|
||||
/** Subscribe to agent events for local direct chat */
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import Layout from './pages/layout'
|
|||
import HomePage from './pages/home'
|
||||
import ToolsPage from './pages/tools'
|
||||
import SkillsPage from './pages/skills'
|
||||
import CronsPage from './pages/crons'
|
||||
|
||||
const router = createHashRouter([
|
||||
{
|
||||
|
|
@ -13,6 +14,7 @@ const router = createHashRouter([
|
|||
{ path: 'chat' },
|
||||
{ path: 'tools', element: <ToolsPage /> },
|
||||
{ path: 'skills', element: <SkillsPage /> },
|
||||
{ path: 'crons', element: <CronsPage /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
|
|
|||
215
apps/desktop/src/components/cron-job-list.tsx
Normal file
215
apps/desktop/src/components/cron-job-list.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { useState } from 'react'
|
||||
import { Switch } from '@multica/ui/components/ui/switch'
|
||||
import { Button } from '@multica/ui/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import {
|
||||
RotateClockwiseIcon,
|
||||
Delete02Icon,
|
||||
Loading03Icon,
|
||||
Time04Icon,
|
||||
CheckmarkCircle02Icon,
|
||||
CancelCircleIcon,
|
||||
AlertCircleIcon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import type { CronJobInfo } from '../hooks/use-cron-jobs'
|
||||
|
||||
interface CronJobListProps {
|
||||
jobs: CronJobInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onToggleJob: (jobId: string) => Promise<void>
|
||||
onRemoveJob: (jobId: string) => Promise<void>
|
||||
onRefresh: () => Promise<void>
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: CronJobInfo['lastStatus'] }) {
|
||||
if (!status) {
|
||||
return (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
no runs
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const config = {
|
||||
ok: { icon: CheckmarkCircle02Icon, className: 'text-emerald-600', label: 'ok' },
|
||||
error: { icon: CancelCircleIcon, className: 'text-destructive', label: 'error' },
|
||||
skipped: { icon: AlertCircleIcon, className: 'text-yellow-600', label: 'skipped' },
|
||||
}[status]
|
||||
|
||||
return (
|
||||
<span className={`flex items-center gap-1 text-xs ${config.className}`}>
|
||||
<HugeiconsIcon icon={config.icon} className="size-3.5" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoString: string): string {
|
||||
const date = new Date(isoString)
|
||||
const now = Date.now()
|
||||
const diffMs = date.getTime() - now
|
||||
|
||||
if (Math.abs(diffMs) < 60_000) return 'just now'
|
||||
|
||||
const absMs = Math.abs(diffMs)
|
||||
const minutes = Math.floor(absMs / 60_000)
|
||||
const hours = Math.floor(absMs / 3_600_000)
|
||||
const days = Math.floor(absMs / 86_400_000)
|
||||
|
||||
const unit = days > 0 ? `${days}d` : hours > 0 ? `${hours}h` : `${minutes}m`
|
||||
return diffMs > 0 ? `in ${unit}` : `${unit} ago`
|
||||
}
|
||||
|
||||
export function CronJobList({
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
onToggleJob,
|
||||
onRemoveJob,
|
||||
onRefresh,
|
||||
}: CronJobListProps) {
|
||||
const [togglingJobs, setTogglingJobs] = useState<Set<string>>(new Set())
|
||||
const [removingJobs, setRemovingJobs] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleToggle = async (jobId: string) => {
|
||||
setTogglingJobs((prev) => new Set(prev).add(jobId))
|
||||
try {
|
||||
await onToggleJob(jobId)
|
||||
} finally {
|
||||
setTogglingJobs((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(jobId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (jobId: string) => {
|
||||
setRemovingJobs((prev) => new Set(prev).add(jobId))
|
||||
try {
|
||||
await onRemoveJob(jobId)
|
||||
} finally {
|
||||
setRemovingJobs((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(jobId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && jobs.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<HugeiconsIcon icon={Loading03Icon} className="size-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading cron jobs...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{jobs.filter((j) => j.enabled).length} of {jobs.length} jobs enabled
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="gap-1.5"
|
||||
disabled={loading}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={loading ? Loading03Icon : RotateClockwiseIcon}
|
||||
className={`size-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{jobs.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<HugeiconsIcon icon={Time04Icon} className="size-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No scheduled tasks</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Use the cron tool in Chat to create one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Job list */}
|
||||
{jobs.length > 0 && (
|
||||
<div className="border rounded-lg divide-y">
|
||||
{jobs.map((job) => {
|
||||
const isToggling = togglingJobs.has(job.id)
|
||||
const isRemoving = removingJobs.has(job.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className="flex items-center gap-4 px-4 py-3 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{job.name}</span>
|
||||
<StatusBadge status={job.lastStatus} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{job.schedule}</span>
|
||||
{job.nextRunAt && job.enabled && (
|
||||
<span>next: {formatRelativeTime(job.nextRunAt)}</span>
|
||||
)}
|
||||
{job.lastRunAt && (
|
||||
<span>last: {formatRelativeTime(job.lastRunAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
{job.lastError && (
|
||||
<p className="text-xs text-destructive mt-0.5 truncate">{job.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemove(job.id)}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
{isRemoving ? (
|
||||
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{isToggling && (
|
||||
<HugeiconsIcon icon={Loading03Icon} className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<Switch
|
||||
checked={job.enabled}
|
||||
onCheckedChange={() => handleToggle(job.id)}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CronJobList
|
||||
107
apps/desktop/src/hooks/use-cron-jobs.ts
Normal file
107
apps/desktop/src/hooks/use-cron-jobs.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface CronJobInfo {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
schedule: string
|
||||
sessionTarget: string
|
||||
nextRunAt: string | null
|
||||
lastStatus: 'ok' | 'error' | 'skipped' | null
|
||||
lastRunAt: string | null
|
||||
lastDurationMs: number | null
|
||||
lastError: string | null
|
||||
}
|
||||
|
||||
export interface UseCronJobsReturn {
|
||||
jobs: CronJobInfo[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
toggleJob: (jobId: string) => Promise<void>
|
||||
removeJob: (jobId: string) => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useCronJobs(): UseCronJobsReturn {
|
||||
const [jobs, setJobs] = useState<CronJobInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchJobs = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = window.electronAPI
|
||||
? await window.electronAPI.cron.list()
|
||||
: await window.ipcRenderer.invoke('cron:list')
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
setJobs(result)
|
||||
} else {
|
||||
setError('Invalid response from cron:list')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch cron jobs')
|
||||
setJobs([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs()
|
||||
}, [fetchJobs])
|
||||
|
||||
const toggleJob = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
const result = window.electronAPI
|
||||
? await window.electronAPI.cron.toggle(jobId)
|
||||
: await window.ipcRenderer.invoke('cron:toggle', jobId)
|
||||
|
||||
const typed = result as { error?: string; id?: string; enabled?: boolean }
|
||||
if (typed.error) {
|
||||
setError(typed.error)
|
||||
return
|
||||
}
|
||||
|
||||
setJobs((prev) =>
|
||||
prev.map((job) =>
|
||||
job.id === jobId ? { ...job, enabled: typed.enabled ?? !job.enabled } : job
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to toggle job')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const removeJob = useCallback(async (jobId: string) => {
|
||||
try {
|
||||
const result = window.electronAPI
|
||||
? await window.electronAPI.cron.remove(jobId)
|
||||
: await window.ipcRenderer.invoke('cron:remove', jobId)
|
||||
|
||||
const typed = result as { error?: string; ok?: boolean }
|
||||
if (typed.error) {
|
||||
setError(typed.error)
|
||||
return
|
||||
}
|
||||
|
||||
setJobs((prev) => prev.filter((job) => job.id !== jobId))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to remove job')
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
toggleJob,
|
||||
removeJob,
|
||||
refresh: fetchJobs,
|
||||
}
|
||||
}
|
||||
|
||||
export default useCronJobs
|
||||
43
apps/desktop/src/pages/crons.tsx
Normal file
43
apps/desktop/src/pages/crons.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@multica/ui/components/ui/card'
|
||||
import { useCronJobs } from '../hooks/use-cron-jobs'
|
||||
import { CronJobList } from '../components/cron-job-list'
|
||||
|
||||
export default function CronsPage() {
|
||||
const {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
toggleJob,
|
||||
removeJob,
|
||||
refresh,
|
||||
} = useCronJobs()
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cron Jobs</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage scheduled tasks. Create new jobs by asking the Agent in Chat.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CronJobList
|
||||
jobs={jobs}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleJob={toggleJob}
|
||||
onRemoveJob={removeJob}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
CodeIcon,
|
||||
PlugIcon,
|
||||
Comment01Icon,
|
||||
Time04Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
|
||||
|
|
@ -18,6 +19,7 @@ const tabs = [
|
|||
{ path: '/chat', label: 'Chat', icon: Comment01Icon },
|
||||
{ path: '/tools', label: 'Tools', icon: CodeIcon },
|
||||
{ path: '/skills', label: 'Skills', icon: PlugIcon },
|
||||
{ path: '/crons', label: 'Cron', icon: Time04Icon },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue