mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
feat(kanban): show dashboard cron jobs across profiles
Salvages #27568 by @SerenityTn. Dashboard cron page now lists cron jobs from all profiles, with profile-aware filter UI and storage routing. Includes test coverage for cross-profile listing, mutation, deletion, and validation. Also fixes orphan conflict markers in config.py left by an earlier salvage merge (kanban.dispatch_stale_timeout_seconds was double-nested in HEAD/PR markers from #28452 salvage of #23790).
This commit is contained in:
parent
264e85b3dd
commit
1a5172742e
4 changed files with 416 additions and 62 deletions
|
|
@ -2560,73 +2560,181 @@ class CronJobUpdate(BaseModel):
|
|||
updates: dict
|
||||
|
||||
|
||||
_CRON_PROFILE_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _cron_profile_dicts() -> List[Dict[str, Any]]:
|
||||
"""Return dashboard profile records, falling back to a directory scan."""
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
try:
|
||||
return [_profile_to_dict(p) for p in profiles_mod.list_profiles()]
|
||||
except Exception:
|
||||
_log.exception("Failed to list profiles for cron dashboard; falling back to directory scan")
|
||||
return _fallback_profile_dicts(profiles_mod)
|
||||
|
||||
|
||||
def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]:
|
||||
"""Resolve a profile query value to (profile_name, HERMES_HOME)."""
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
raw = (profile or "default").strip() or "default"
|
||||
try:
|
||||
canon = profiles_mod.normalize_profile_name(raw)
|
||||
profiles_mod.validate_profile_name(canon)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if not profiles_mod.profile_exists(canon):
|
||||
raise HTTPException(status_code=404, detail=f"Profile '{canon}' does not exist.")
|
||||
return canon, profiles_mod.get_profile_dir(canon)
|
||||
|
||||
|
||||
def _annotate_cron_job(job: Dict[str, Any], profile: str, home: Path) -> Dict[str, Any]:
|
||||
annotated = dict(job)
|
||||
annotated["profile"] = profile
|
||||
annotated["profile_name"] = profile
|
||||
annotated["hermes_home"] = str(home)
|
||||
annotated["is_default_profile"] = profile == "default"
|
||||
return annotated
|
||||
|
||||
|
||||
def _call_cron_for_profile(profile: Optional[str], func_name: str, *args, **kwargs):
|
||||
"""Run cron.jobs helpers against the selected profile's cron directory.
|
||||
|
||||
cron.jobs keeps CRON_DIR/JOBS_FILE/OUTPUT_DIR as module globals resolved
|
||||
from the process HERMES_HOME at import time. The dashboard is a single
|
||||
process that can inspect many profiles, so temporarily retarget those
|
||||
globals while holding a lock and restore them immediately after the call.
|
||||
"""
|
||||
profile_name, home = _cron_profile_home(profile)
|
||||
with _CRON_PROFILE_LOCK:
|
||||
from cron import jobs as cron_jobs
|
||||
|
||||
old_cron_dir = cron_jobs.CRON_DIR
|
||||
old_jobs_file = cron_jobs.JOBS_FILE
|
||||
old_output_dir = cron_jobs.OUTPUT_DIR
|
||||
cron_jobs.CRON_DIR = home / "cron"
|
||||
cron_jobs.JOBS_FILE = cron_jobs.CRON_DIR / "jobs.json"
|
||||
cron_jobs.OUTPUT_DIR = cron_jobs.CRON_DIR / "output"
|
||||
try:
|
||||
result = getattr(cron_jobs, func_name)(*args, **kwargs)
|
||||
finally:
|
||||
cron_jobs.CRON_DIR = old_cron_dir
|
||||
cron_jobs.JOBS_FILE = old_jobs_file
|
||||
cron_jobs.OUTPUT_DIR = old_output_dir
|
||||
|
||||
if isinstance(result, list):
|
||||
return [_annotate_cron_job(j, profile_name, home) for j in result]
|
||||
if isinstance(result, dict):
|
||||
return _annotate_cron_job(result, profile_name, home)
|
||||
return result
|
||||
|
||||
|
||||
def _find_cron_job_profile(job_id: str) -> Optional[str]:
|
||||
for profile in _cron_profile_dicts():
|
||||
name = str(profile.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
jobs = _call_cron_for_profile(name, "list_jobs", True)
|
||||
if any(j.get("id") == job_id or j.get("name") == job_id for j in jobs):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
@app.get("/api/cron/jobs")
|
||||
async def list_cron_jobs():
|
||||
from cron.jobs import list_jobs
|
||||
return list_jobs(include_disabled=True)
|
||||
async def list_cron_jobs(profile: str = "all"):
|
||||
requested = (profile or "all").strip()
|
||||
if requested.lower() != "all":
|
||||
return _call_cron_for_profile(requested, "list_jobs", True)
|
||||
|
||||
jobs: List[Dict[str, Any]] = []
|
||||
for item in _cron_profile_dicts():
|
||||
name = str(item.get("name") or "")
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
jobs.extend(_call_cron_for_profile(name, "list_jobs", True))
|
||||
except Exception:
|
||||
_log.exception("Failed to list cron jobs for profile %s", name)
|
||||
return jobs
|
||||
|
||||
|
||||
@app.get("/api/cron/jobs/{job_id}")
|
||||
async def get_cron_job(job_id: str):
|
||||
from cron.jobs import get_job
|
||||
job = get_job(job_id)
|
||||
async def get_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job = _call_cron_for_profile(selected, "get_job", job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/api/cron/jobs")
|
||||
async def create_cron_job(body: CronJobCreate):
|
||||
from cron.jobs import create_job
|
||||
async def create_cron_job(body: CronJobCreate, profile: str = "default"):
|
||||
try:
|
||||
job = create_job(prompt=body.prompt, schedule=body.schedule,
|
||||
name=body.name, deliver=body.deliver)
|
||||
return job
|
||||
return _call_cron_for_profile(
|
||||
profile,
|
||||
"create_job",
|
||||
prompt=body.prompt,
|
||||
schedule=body.schedule,
|
||||
name=body.name,
|
||||
deliver=body.deliver,
|
||||
)
|
||||
except Exception as e:
|
||||
_log.exception("POST /api/cron/jobs failed")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@app.put("/api/cron/jobs/{job_id}")
|
||||
async def update_cron_job(job_id: str, body: CronJobUpdate):
|
||||
from cron.jobs import update_job
|
||||
job = update_job(job_id, body.updates)
|
||||
async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job = _call_cron_for_profile(selected, "update_job", job_id, body.updates)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/api/cron/jobs/{job_id}/pause")
|
||||
async def pause_cron_job(job_id: str):
|
||||
from cron.jobs import pause_job
|
||||
job = pause_job(job_id)
|
||||
async def pause_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job = _call_cron_for_profile(selected, "pause_job", job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/api/cron/jobs/{job_id}/resume")
|
||||
async def resume_cron_job(job_id: str):
|
||||
from cron.jobs import resume_job
|
||||
job = resume_job(job_id)
|
||||
async def resume_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job = _call_cron_for_profile(selected, "resume_job", job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/api/cron/jobs/{job_id}/trigger")
|
||||
async def trigger_cron_job(job_id: str):
|
||||
from cron.jobs import trigger_job
|
||||
job = trigger_job(job_id)
|
||||
async def trigger_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
job = _call_cron_for_profile(selected, "trigger_job", job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@app.delete("/api/cron/jobs/{job_id}")
|
||||
async def delete_cron_job(job_id: str):
|
||||
from cron.jobs import remove_job
|
||||
if not remove_job(job_id):
|
||||
async def delete_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
if not _call_cron_for_profile(selected, "remove_job", job_id):
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
|
|
|||
172
tests/hermes_cli/test_web_server_cron_profiles.py
Normal file
172
tests/hermes_cli/test_web_server_cron_profiles.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""Regression tests for dashboard cron job profile routing."""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolated_profiles(tmp_path, monkeypatch):
|
||||
"""Give profile discovery an isolated default home with one named profile."""
|
||||
from hermes_cli import profiles
|
||||
|
||||
default_home = tmp_path / ".hermes"
|
||||
profiles_root = default_home / "profiles"
|
||||
worker_home = profiles_root / "worker_alpha"
|
||||
|
||||
for home in (default_home, worker_home):
|
||||
(home / "cron").mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text("model: test-model\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
||||
return {"default": default_home, "worker_alpha": worker_home}
|
||||
|
||||
|
||||
def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_profiles):
|
||||
from cron import jobs as cron_jobs
|
||||
from hermes_cli import web_server
|
||||
|
||||
old_cron_dir = cron_jobs.CRON_DIR
|
||||
old_jobs_file = cron_jobs.JOBS_FILE
|
||||
old_output_dir = cron_jobs.OUTPUT_DIR
|
||||
|
||||
job = web_server._call_cron_for_profile(
|
||||
"worker_alpha",
|
||||
"create_job",
|
||||
prompt="run scheduled task",
|
||||
schedule="every 1h",
|
||||
name="worker-alpha-scan",
|
||||
)
|
||||
|
||||
assert job["profile"] == "worker_alpha"
|
||||
assert job["profile_name"] == "worker_alpha"
|
||||
assert job["hermes_home"] == str(isolated_profiles["worker_alpha"])
|
||||
assert job["is_default_profile"] is False
|
||||
assert (isolated_profiles["worker_alpha"] / "cron" / "jobs.json").exists()
|
||||
assert not (isolated_profiles["default"] / "cron" / "jobs.json").exists()
|
||||
|
||||
assert cron_jobs.CRON_DIR == old_cron_dir
|
||||
assert cron_jobs.JOBS_FILE == old_jobs_file
|
||||
assert cron_jobs.OUTPUT_DIR == old_output_dir
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_cron_jobs_all_includes_default_and_named_profiles(isolated_profiles):
|
||||
from hermes_cli import web_server
|
||||
|
||||
default_job = web_server._call_cron_for_profile(
|
||||
"default",
|
||||
"create_job",
|
||||
prompt="default heartbeat",
|
||||
schedule="every 2h",
|
||||
name="default-heartbeat",
|
||||
)
|
||||
worker_job = web_server._call_cron_for_profile(
|
||||
"worker_alpha",
|
||||
"create_job",
|
||||
prompt="worker heartbeat",
|
||||
schedule="every 3h",
|
||||
name="worker-alpha-heartbeat",
|
||||
)
|
||||
|
||||
jobs = await web_server.list_cron_jobs(profile="all")
|
||||
by_id = {job["id"]: job for job in jobs}
|
||||
|
||||
assert set(by_id) >= {default_job["id"], worker_job["id"]}
|
||||
assert by_id[default_job["id"]]["profile"] == "default"
|
||||
assert by_id[default_job["id"]]["is_default_profile"] is True
|
||||
assert by_id[default_job["id"]]["hermes_home"] == str(isolated_profiles["default"])
|
||||
assert by_id[worker_job["id"]]["profile"] == "worker_alpha"
|
||||
assert by_id[worker_job["id"]]["is_default_profile"] is False
|
||||
assert by_id[worker_job["id"]]["hermes_home"] == str(isolated_profiles["worker_alpha"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_cron_jobs_specific_profile_filters_results(isolated_profiles):
|
||||
from hermes_cli import web_server
|
||||
|
||||
web_server._call_cron_for_profile(
|
||||
"default",
|
||||
"create_job",
|
||||
prompt="default only",
|
||||
schedule="every 2h",
|
||||
name="default-only",
|
||||
)
|
||||
worker_job = web_server._call_cron_for_profile(
|
||||
"worker_alpha",
|
||||
"create_job",
|
||||
prompt="worker only",
|
||||
schedule="every 3h",
|
||||
name="worker-only",
|
||||
)
|
||||
|
||||
jobs = await web_server.list_cron_jobs(profile="worker_alpha")
|
||||
|
||||
assert [job["id"] for job in jobs] == [worker_job["id"]]
|
||||
assert jobs[0]["profile"] == "worker_alpha"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_profiles):
|
||||
from hermes_cli import web_server
|
||||
|
||||
worker_job = web_server._call_cron_for_profile(
|
||||
"worker_alpha",
|
||||
"create_job",
|
||||
prompt="managed by named profile",
|
||||
schedule="every 1h",
|
||||
name="named-profile-job",
|
||||
)
|
||||
|
||||
paused = await web_server.pause_cron_job(worker_job["id"])
|
||||
assert paused["profile"] == "worker_alpha"
|
||||
assert paused["enabled"] is False
|
||||
|
||||
default_jobs = await web_server.list_cron_jobs(profile="default")
|
||||
worker_jobs = await web_server.list_cron_jobs(profile="worker_alpha")
|
||||
|
||||
assert default_jobs == []
|
||||
assert len(worker_jobs) == 1
|
||||
assert worker_jobs[0]["id"] == worker_job["id"]
|
||||
assert worker_jobs[0]["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_delete_with_profile_deletes_only_target_profile(isolated_profiles):
|
||||
from hermes_cli import web_server
|
||||
|
||||
default_job = web_server._call_cron_for_profile(
|
||||
"default",
|
||||
"create_job",
|
||||
prompt="same-ish default",
|
||||
schedule="every 1h",
|
||||
name="shared-name",
|
||||
)
|
||||
worker_job = web_server._call_cron_for_profile(
|
||||
"worker_alpha",
|
||||
"create_job",
|
||||
prompt="same-ish worker",
|
||||
schedule="every 1h",
|
||||
name="shared-name-worker",
|
||||
)
|
||||
|
||||
deleted = await web_server.delete_cron_job(worker_job["id"], profile="worker_alpha")
|
||||
assert deleted == {"ok": True}
|
||||
|
||||
remaining_default = await web_server.list_cron_jobs(profile="default")
|
||||
remaining_worker = await web_server.list_cron_jobs(profile="worker_alpha")
|
||||
assert [job["id"] for job in remaining_default] == [default_job["id"]]
|
||||
assert remaining_worker == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_profile_validation_errors(isolated_profiles):
|
||||
from hermes_cli import web_server
|
||||
|
||||
with pytest.raises(HTTPException) as bad_name:
|
||||
await web_server.list_cron_jobs(profile="../bad")
|
||||
assert bad_name.value.status_code == 400
|
||||
|
||||
with pytest.raises(HTTPException) as missing:
|
||||
await web_server.list_cron_jobs(profile="missing_profile")
|
||||
assert missing.value.status_code == 404
|
||||
|
|
@ -138,21 +138,22 @@ export const api = {
|
|||
},
|
||||
|
||||
// Cron jobs
|
||||
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
|
||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
|
||||
fetchJSON<CronJob>("/api/cron/jobs", {
|
||||
getCronJobs: (profile = "all") =>
|
||||
fetchJSON<CronJob[]>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`),
|
||||
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") =>
|
||||
fetchJSON<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(job),
|
||||
}),
|
||||
pauseCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
|
||||
resumeCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
|
||||
triggerCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
|
||||
deleteCronJob: (id: string) =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
|
||||
pauseCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
|
||||
resumeCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/resume?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
|
||||
triggerCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/trigger?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
|
||||
deleteCronJob: (id: string, profile = "default") =>
|
||||
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
|
||||
|
||||
// Profiles (minimal)
|
||||
getProfiles: () =>
|
||||
|
|
@ -553,6 +554,10 @@ export interface ModelsAnalyticsResponse {
|
|||
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
profile?: string | null;
|
||||
profile_name?: string | null;
|
||||
hermes_home?: string | null;
|
||||
is_default_profile?: boolean;
|
||||
name?: string | null;
|
||||
prompt?: string | null;
|
||||
script?: string | null;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
|||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { H2 } from "@/components/NouiTypography";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CronJob } from "@/lib/api";
|
||||
import type { CronJob, ProfileInfo } from "@/lib/api";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
||||
|
|
@ -69,6 +69,24 @@ function getJobState(job: CronJob): string {
|
|||
return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
|
||||
}
|
||||
|
||||
function getJobProfile(job: CronJob): string {
|
||||
return asText(job.profile) || asText(job.profile_name) || "default";
|
||||
}
|
||||
|
||||
function getJobKey(job: CronJob): string {
|
||||
return `${getJobProfile(job)}:${job.id}`;
|
||||
}
|
||||
|
||||
function splitJobKey(key: string): { profile: string; id: string } {
|
||||
const idx = key.indexOf(":");
|
||||
if (idx === -1) return { profile: "default", id: key };
|
||||
return { profile: key.slice(0, idx) || "default", id: key.slice(idx + 1) };
|
||||
}
|
||||
|
||||
function profileLabel(profile: string): string {
|
||||
return profile === "default" ? "default" : profile;
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
|
||||
enabled: "success",
|
||||
scheduled: "success",
|
||||
|
|
@ -79,6 +97,8 @@ const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
|
|||
|
||||
export default function CronPage() {
|
||||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [selectedProfile, setSelectedProfile] = useState("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
|
@ -96,14 +116,22 @@ export default function CronPage() {
|
|||
});
|
||||
const [deliver, setDeliver] = useState("local");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const createProfile = selectedProfile === "all" ? "default" : selectedProfile;
|
||||
|
||||
const loadJobs = useCallback(() => {
|
||||
api
|
||||
.getCronJobs()
|
||||
.getCronJobs(selectedProfile)
|
||||
.then(setJobs)
|
||||
.catch(() => showToast(t.common.loading, "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [showToast, t.common.loading]);
|
||||
}, [selectedProfile, showToast, t.common.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.getProfiles()
|
||||
.then((res) => setProfiles(res.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
|
|
@ -116,12 +144,15 @@ export default function CronPage() {
|
|||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.createCronJob({
|
||||
prompt: prompt.trim(),
|
||||
schedule: schedule.trim(),
|
||||
name: name.trim() || undefined,
|
||||
deliver,
|
||||
});
|
||||
await api.createCronJob(
|
||||
{
|
||||
prompt: prompt.trim(),
|
||||
schedule: schedule.trim(),
|
||||
name: name.trim() || undefined,
|
||||
deliver,
|
||||
},
|
||||
createProfile,
|
||||
);
|
||||
showToast(t.common.create + " ✓", "success");
|
||||
setPrompt("");
|
||||
setSchedule("");
|
||||
|
|
@ -139,14 +170,15 @@ export default function CronPage() {
|
|||
const handlePauseResume = async (job: CronJob) => {
|
||||
try {
|
||||
const isPaused = getJobState(job) === "paused";
|
||||
const profile = getJobProfile(job);
|
||||
if (isPaused) {
|
||||
await api.resumeCronJob(job.id);
|
||||
await api.resumeCronJob(job.id, profile);
|
||||
showToast(
|
||||
`${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`,
|
||||
"success",
|
||||
);
|
||||
} else {
|
||||
await api.pauseCronJob(job.id);
|
||||
await api.pauseCronJob(job.id, profile);
|
||||
showToast(
|
||||
`${t.cron.pause}: "${truncateText(getJobTitle(job), 30)}"`,
|
||||
"success",
|
||||
|
|
@ -160,7 +192,7 @@ export default function CronPage() {
|
|||
|
||||
const handleTrigger = async (job: CronJob) => {
|
||||
try {
|
||||
await api.triggerCronJob(job.id);
|
||||
await api.triggerCronJob(job.id, getJobProfile(job));
|
||||
showToast(
|
||||
`${t.cron.triggerNow}: "${truncateText(getJobTitle(job), 30)}"`,
|
||||
"success",
|
||||
|
|
@ -173,10 +205,11 @@ export default function CronPage() {
|
|||
|
||||
const jobDelete = useConfirmDelete({
|
||||
onDelete: useCallback(
|
||||
async (id: string) => {
|
||||
const job = jobs.find((j) => j.id === id);
|
||||
async (key: string) => {
|
||||
const { profile, id } = splitJobKey(key);
|
||||
const job = jobs.find((j) => getJobKey(j) === key);
|
||||
try {
|
||||
await api.deleteCronJob(id);
|
||||
await api.deleteCronJob(id, profile);
|
||||
showToast(
|
||||
`${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`,
|
||||
"success",
|
||||
|
|
@ -216,7 +249,7 @@ export default function CronPage() {
|
|||
}
|
||||
|
||||
const pendingJob = jobDelete.pendingId
|
||||
? jobs.find((j) => j.id === jobDelete.pendingId)
|
||||
? jobs.find((j) => getJobKey(j) === jobDelete.pendingId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
|
@ -270,6 +303,21 @@ export default function CronPage() {
|
|||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-profile">Profile</Label>
|
||||
<Select
|
||||
id="cron-profile"
|
||||
value={createProfile}
|
||||
onValueChange={(v) => setSelectedProfile(v)}
|
||||
>
|
||||
{profiles.map((profile) => (
|
||||
<SelectOption key={profile.name} value={profile.name}>
|
||||
{profileLabel(profile.name)}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
|
||||
<Input
|
||||
|
|
@ -345,13 +393,31 @@ export default function CronPage() {
|
|||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{t.cron.scheduledJobs} ({jobs.length})
|
||||
</H2>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{t.cron.scheduledJobs} ({jobs.length})
|
||||
</H2>
|
||||
|
||||
<div className="grid gap-1 min-w-[220px]">
|
||||
<Label htmlFor="cron-profile-filter">Profile</Label>
|
||||
<Select
|
||||
id="cron-profile-filter"
|
||||
value={selectedProfile}
|
||||
onValueChange={(v) => setSelectedProfile(v)}
|
||||
>
|
||||
<SelectOption value="all">All profiles</SelectOption>
|
||||
{profiles.map((profile) => (
|
||||
<SelectOption key={profile.name} value={profile.name}>
|
||||
{profileLabel(profile.name)}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 && (
|
||||
<Card>
|
||||
|
|
@ -367,9 +433,11 @@ export default function CronPage() {
|
|||
const title = getJobTitle(job);
|
||||
const hasName = Boolean(getJobName(job));
|
||||
const deliver = asText(job.deliver);
|
||||
const profile = getJobProfile(job);
|
||||
const jobKey = getJobKey(job);
|
||||
|
||||
return (
|
||||
<Card key={job.id}>
|
||||
<Card key={jobKey}>
|
||||
<CardContent className="flex items-start gap-4 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
|
|
@ -379,6 +447,7 @@ export default function CronPage() {
|
|||
<Badge tone={STATUS_TONE[state] ?? "secondary"}>
|
||||
{state}
|
||||
</Badge>
|
||||
<Badge tone="outline">{profileLabel(profile)}</Badge>
|
||||
{deliver && deliver !== "local" && (
|
||||
<Badge tone="outline">{deliver}</Badge>
|
||||
)}
|
||||
|
|
@ -436,7 +505,7 @@ export default function CronPage() {
|
|||
size="icon"
|
||||
title={t.common.delete}
|
||||
aria-label={t.common.delete}
|
||||
onClick={() => jobDelete.requestDelete(job.id)}
|
||||
onClick={() => jobDelete.requestDelete(jobKey)}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue