From fc3fd6bb6b3cb4aa01d71bb52c0092ec4b5db1b8 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Tue, 12 May 2026 13:42:14 -0400 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20UI=20polish=20=E2=80=94=20mod?= =?UTF-8?q?als,=20layout,=20consistency,=20test=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard UX polish pass — consolidates create forms into modals triggered from the page header, fixes layout inconsistencies, adds scroll-to navigation for the Keys page, and aligns the TokenBar with the design system. Changes: - App.tsx: add padding to sidebar header - resolve-page-title.ts: add missing routes, better fallback title - en.ts: fix nav labels (Profiles was 'profiles : multi agents') - ModelsPage: two-col layout, auxiliary tasks modal, TokenBar redesign - ProfilesPage: create button in header, form in modal, Checkbox component - CronPage: create button in header, form in modal - EnvPage: scroll-to sub-nav in header, fix text overflow Modal and dialog standardization: - Replace all native confirm()/window.confirm() with ConfirmDialog (OAuthProvidersCard, PluginsPage, ModelsPage, ConfigPage) - Add useModalBehavior hook (Escape-to-close, scroll lock, focus restore) - Apply hook to ProfilesPage, CronPage, AuxiliaryTasksModal Component fixes (from PR review): - Checkbox: fix controlled/uncontrolled mismatch, add focus-visible ring - TokenBar: add rounded-full to legend dots, remove dead code CI/test fixes: - Fix TS unused imports (noUnusedLocals), type-narrow PickerTarget union - Add windows-footgun suppression on platform-guarded os.killpg - Fix 19 stale unit tests + 9 e2e tests broken by recent main changes - Restore minimal example-dashboard plugin for plugin auth test --- .../example-dashboard/dashboard/manifest.json | 14 + .../example-dashboard/dashboard/plugin_api.py | 17 + tests/agent/test_auxiliary_client.py | 1 + tests/e2e/conftest.py | 3 + tests/gateway/test_config.py | 4 +- tests/gateway/test_tts_media_routing.py | 18 +- tests/gateway/test_update_streaming.py | 3 + tests/gateway/test_verbose_command.py | 14 +- .../test_dashboard_profiles_nav_label.py | 7 +- .../hermes_cli/test_update_gateway_restart.py | 27 +- .../run_agent/test_async_httpx_del_neuter.py | 2 +- tests/run_agent/test_provider_parity.py | 3 +- tests/test_ctx_halving_fix.py | 1 + tests/tools/test_vision_native_fast_path.py | 10 +- tools/process_registry.py | 2 +- web/src/App.tsx | 2 +- web/src/components/OAuthProvidersCard.tsx | 20 +- web/src/components/ui/checkbox.tsx | 61 +++ web/src/hooks/useModalBehavior.ts | 44 +++ web/src/i18n/en.ts | 2 +- web/src/lib/resolve-page-title.ts | 7 + web/src/pages/ConfigPage.tsx | 28 +- web/src/pages/CronPage.tsx | 180 ++++++--- web/src/pages/EnvPage.tsx | 78 +++- web/src/pages/ModelsPage.tsx | 370 ++++++++++++------ web/src/pages/PluginsPage.tsx | 30 +- web/src/pages/ProfilesPage.tsx | 135 +++++-- 27 files changed, 788 insertions(+), 295 deletions(-) create mode 100644 plugins/example-dashboard/dashboard/manifest.json create mode 100644 plugins/example-dashboard/dashboard/plugin_api.py create mode 100644 web/src/components/ui/checkbox.tsx create mode 100644 web/src/hooks/useModalBehavior.ts diff --git a/plugins/example-dashboard/dashboard/manifest.json b/plugins/example-dashboard/dashboard/manifest.json new file mode 100644 index 00000000000..68a2e9b895c --- /dev/null +++ b/plugins/example-dashboard/dashboard/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "example", + "label": "Example", + "description": "Example dashboard plugin — used by test suite for auth coverage", + "icon": "Sparkles", + "version": "1.0.0", + "tab": { + "path": "/example", + "position": "after:skills" + }, + "slots": [], + "entry": "dist/index.js", + "api": "plugin_api.py" +} diff --git a/plugins/example-dashboard/dashboard/plugin_api.py b/plugins/example-dashboard/dashboard/plugin_api.py new file mode 100644 index 00000000000..3e850298a09 --- /dev/null +++ b/plugins/example-dashboard/dashboard/plugin_api.py @@ -0,0 +1,17 @@ +"""Example dashboard plugin — backend API routes. + +Mounted at /api/plugins/example/ by the dashboard plugin system. + +This minimal plugin exists so the test suite has a stable, side-effect-free +GET endpoint to verify that plugin API routes work with auth. +""" + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/hello") +async def hello(): + """Simple greeting endpoint to demonstrate plugin API routes.""" + return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"} diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index cdac34d3282..c25ca219379 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -660,6 +660,7 @@ class TestAuxiliaryPoolAwareness: with ( patch("agent.auxiliary_client.load_pool", return_value=_Pool()), patch("agent.auxiliary_client.OpenAI") as mock_openai, + patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None), ): from agent.auxiliary_client import _try_nous diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 76b14e31793..332cccee497 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -222,6 +222,9 @@ def make_runner(platform: Platform, session_entry: SessionEntry = None) -> "Gate runner._capture_gateway_honcho_if_configured = lambda *a, **kw: None runner._emit_gateway_run_progress = AsyncMock() + # Disable destructive slash confirm gate so /new executes immediately + runner._read_user_config = lambda: {"approvals": {"destructive_slash_confirm": False}} + runner.pairing_store = MagicMock() runner.pairing_store._is_rate_limited = MagicMock(return_value=False) runner.pairing_store.generate_code = MagicMock(return_value="ABC123") diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index c53e34b757e..c59b27d8001 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -176,8 +176,8 @@ class TestStreamingConfig: "fresh_final_after_seconds": "oops", } ) - assert restored.edit_interval == 1.0 - assert restored.buffer_threshold == 40 + assert restored.edit_interval == 0.8 + assert restored.buffer_threshold == 24 assert restored.fresh_final_after_seconds == 60.0 diff --git a/tests/gateway/test_tts_media_routing.py b/tests/gateway/test_tts_media_routing.py index 0ef37deb3ee..ec93c33f75c 100644 --- a/tests/gateway/test_tts_media_routing.py +++ b/tests/gateway/test_tts_media_routing.py @@ -8,7 +8,7 @@ only renders as a voice bubble when explicitly flagged) and via """ from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -106,6 +106,16 @@ async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_ adapter.send_document.assert_not_awaited() +def _fake_runner(thread_meta): + """Build a fake GatewayRunner-like object with the helper methods needed by + _deliver_media_from_response.""" + runner = SimpleNamespace( + _thread_metadata_for_source=lambda source, anchor=None: thread_meta, + _reply_anchor_for_event=lambda event: None, + ) + return runner + + @pytest.mark.asyncio async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender(): event = _event(thread_id="topic-1") @@ -121,7 +131,7 @@ async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sen ) await GatewayRunner._deliver_media_from_response( - object(), + _fake_runner({"thread_id": "topic-1"}), "MEDIA:/tmp/speech.flac", event, adapter, @@ -150,7 +160,7 @@ async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_doc ) await GatewayRunner._deliver_media_from_response( - object(), + _fake_runner({"thread_id": "topic-1"}), "MEDIA:/tmp/speech.ogg", event, adapter, @@ -181,7 +191,7 @@ async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender( ) await GatewayRunner._deliver_media_from_response( - object(), + _fake_runner({"thread_id": "topic-1"}), "MEDIA:/tmp/speech.mp3", event, adapter, diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py index b1681e1f349..932bd1b0579 100644 --- a/tests/gateway/test_update_streaming.py +++ b/tests/gateway/test_update_streaming.py @@ -45,6 +45,9 @@ def _make_runner(hermes_home=None): runner._pending_messages = {} runner._pending_approvals = {} runner._failed_platforms = {} + # config is accessed by _check_slash_access and quick_commands lookup; + # None makes policy_for_source return a disabled (allow-all) policy. + runner.config = None # Bypass the destructive-slash confirm gate — this test exercises # update-prompt interception, not the confirm prompt. runner._read_user_config = lambda: { diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py index d6debebae59..7b8d0445129 100644 --- a/tests/gateway/test_verbose_command.py +++ b/tests/gateway/test_verbose_command.py @@ -129,7 +129,7 @@ class TestVerboseCommand: @pytest.mark.asyncio async def test_defaults_to_all_when_no_tool_progress_set(self, tmp_path, monkeypatch): - """When tool_progress is not in config, defaults to 'all' then cycles to verbose.""" + """When tool_progress is not in config, defaults to platform default then cycles.""" hermes_home = tmp_path / "hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" @@ -143,17 +143,17 @@ class TestVerboseCommand: runner = _make_runner() result = await runner._handle_verbose_command(_make_event()) - # Telegram default is "all" (high tier) → cycles to verbose - assert "VERBOSE" in result + # Telegram platform default is "new" → cycles to "all" + assert "ALL" in result saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) - assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose" + assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "all" @pytest.mark.asyncio async def test_per_platform_isolation(self, tmp_path, monkeypatch): """Cycling /verbose on Telegram doesn't change Slack's setting. Without a global tool_progress, each platform uses its built-in - default: Telegram = 'all' (high tier), Slack = 'off' (quiet Slack default). + default: Telegram = 'new' (overridden high tier), Slack = 'off' (quiet Slack default). """ hermes_home = tmp_path / "hermes" hermes_home.mkdir() @@ -178,8 +178,8 @@ class TestVerboseCommand: saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) platforms = saved["display"]["platforms"] - # Telegram: all -> verbose (high tier default = all) - assert platforms["telegram"]["tool_progress"] == "verbose" + # Telegram: new -> all (platform default = new) + assert platforms["telegram"]["tool_progress"] == "all" # Slack: off -> new (first /verbose cycle from quiet default) assert platforms["slack"]["tool_progress"] == "new" diff --git a/tests/hermes_cli/test_dashboard_profiles_nav_label.py b/tests/hermes_cli/test_dashboard_profiles_nav_label.py index 583e62ee9fd..924f217bd2e 100644 --- a/tests/hermes_cli/test_dashboard_profiles_nav_label.py +++ b/tests/hermes_cli/test_dashboard_profiles_nav_label.py @@ -2,10 +2,11 @@ from pathlib import Path -def test_profiles_nav_label_uses_short_multi_agents_copy(): +def test_profiles_nav_label_uses_short_copy(): en_i18n = Path(__file__).resolve().parents[2] / "web" / "src" / "i18n" / "en.ts" content = en_i18n.read_text(encoding="utf-8") - assert 'profiles: "profiles : multi agents"' in content - assert "Profiles: Running Multiple Agents" not in content + # Nav label should be the clean short form, not the old verbose string + assert 'profiles: "Profiles"' in content + assert "profiles : multi agents" not in content diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index 5493acb52c0..34c878eca79 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -6,6 +6,7 @@ rather than leaving zombie processes or telling users to manually restart when launchd will auto-respawn. """ +import os import subprocess from types import SimpleNamespace from unittest.mock import patch, MagicMock @@ -1068,13 +1069,18 @@ class TestFindGatewayPidsExclude: def test_excludes_specified_pids(self, monkeypatch): monkeypatch.setattr(gateway_cli, "is_windows", lambda: False) + # Bypass /proc scan so the subprocess (ps) fallback is used + _real_isdir = os.path.isdir + monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p)) + monkeypatch.setattr(gateway_cli, "_get_service_pids", lambda: set()) + monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999}) def fake_run(cmd, **kwargs): return subprocess.CompletedProcess( cmd, 0, stdout=( - "user 100 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n" - "user 200 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n" + "100 python gateway/run.py\n" + "200 python gateway/run.py\n" ), stderr="", ) @@ -1082,19 +1088,24 @@ class TestFindGatewayPidsExclude: monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) monkeypatch.setattr("os.getpid", lambda: 999) - pids = gateway_cli.find_gateway_pids(exclude_pids={100}) + pids = gateway_cli.find_gateway_pids(exclude_pids={100}, all_profiles=True) assert 100 not in pids assert 200 in pids def test_no_exclude_returns_all(self, monkeypatch): monkeypatch.setattr(gateway_cli, "is_windows", lambda: False) + # Bypass /proc scan so the subprocess (ps) fallback is used + _real_isdir = os.path.isdir + monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p)) + monkeypatch.setattr(gateway_cli, "_get_service_pids", lambda: set()) + monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999}) def fake_run(cmd, **kwargs): return subprocess.CompletedProcess( cmd, 0, stdout=( - "user 100 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n" - "user 200 0.0 0.0 0 0 ? S 00:00 0:00 python gateway/run.py\n" + "100 python gateway/run.py\n" + "200 python gateway/run.py\n" ), stderr="", ) @@ -1102,7 +1113,7 @@ class TestFindGatewayPidsExclude: monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run) monkeypatch.setattr("os.getpid", lambda: 999) - pids = gateway_cli.find_gateway_pids() + pids = gateway_cli.find_gateway_pids(all_profiles=True) assert 100 in pids assert 200 in pids @@ -1111,6 +1122,10 @@ class TestFindGatewayPidsExclude: profile_dir.mkdir(parents=True) monkeypatch.setattr(gateway_cli, "is_windows", lambda: False) monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir) + # Bypass /proc scan so the subprocess (ps) fallback is used + _real_isdir = os.path.isdir + monkeypatch.setattr("os.path.isdir", lambda p: False if p == "/proc" else _real_isdir(p)) + monkeypatch.setattr(gateway_cli, "_get_ancestor_pids", lambda: {999}) def fake_run(cmd, **kwargs): return subprocess.CompletedProcess( diff --git a/tests/run_agent/test_async_httpx_del_neuter.py b/tests/run_agent/test_async_httpx_del_neuter.py index e616ea23acb..e91102288c0 100644 --- a/tests/run_agent/test_async_httpx_del_neuter.py +++ b/tests/run_agent/test_async_httpx_del_neuter.py @@ -182,7 +182,7 @@ class TestClientCacheBoundedGrowth: _get_cached_client, ) - key = ("test_replace", True, "", "", "", (), False) + key = ("test_replace", True, "", "", "", (), False, "") # Simulate a stale entry from a closed loop old_loop = asyncio.new_event_loop() diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index 8eb7478b414..f97885a0382 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -945,7 +945,8 @@ class TestAuxiliaryClientProviderPriority: monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) from agent.auxiliary_client import get_text_auxiliary_client with patch("agent.auxiliary_client._read_nous_auth", return_value={"access_token": "nous-tok"}), \ - patch("agent.auxiliary_client.OpenAI") as mock: + patch("agent.auxiliary_client.OpenAI") as mock, \ + patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None): client, model = get_text_auxiliary_client() assert model == "google/gemini-3-flash-preview" diff --git a/tests/test_ctx_halving_fix.py b/tests/test_ctx_halving_fix.py index 0dd3ca4e7eb..afeee84878c 100644 --- a/tests/test_ctx_halving_fix.py +++ b/tests/test_ctx_halving_fix.py @@ -169,6 +169,7 @@ class TestEphemeralMaxOutputTokens: agent.reasoning_config = None agent._is_anthropic_oauth = False agent._ephemeral_max_output_tokens = None + agent._use_long_lived_prefix_cache = False compressor = MagicMock() compressor.context_length = 200_000 diff --git a/tests/tools/test_vision_native_fast_path.py b/tests/tools/test_vision_native_fast_path.py index fce3772de8e..1df3003e5cd 100644 --- a/tests/tools/test_vision_native_fast_path.py +++ b/tests/tools/test_vision_native_fast_path.py @@ -157,8 +157,14 @@ class TestHandleVisionAnalyzeFastPath: from agent.auxiliary_client import set_runtime_main, clear_runtime_main set_runtime_main("openrouter", "anthropic/claude-opus-4.6") try: - coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) - result = asyncio.get_event_loop().run_until_complete(coro) + # Mock decide_image_input_mode to always return "native" so the + # fast path fires regardless of model-catalog state in CI. + with patch( + "agent.image_routing.decide_image_input_mode", + return_value="native", + ): + coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) + result = asyncio.get_event_loop().run_until_complete(coro) finally: clear_runtime_main() diff --git a/tools/process_registry.py b/tools/process_registry.py index 8bbe1f56b7c..405abc04a3c 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -585,7 +585,7 @@ class ProcessRegistry: try: if not _IS_WINDOWS: try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) # windows-footgun: ok — guarded by _IS_WINDOWS check above except (ProcessLookupError, PermissionError, OSError): proc.kill() else: diff --git a/web/src/App.tsx b/web/src/App.tsx index 7e1ca19f134..d7239c2ad11 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -473,7 +473,7 @@ export default function App() { >
diff --git a/web/src/components/OAuthProvidersCard.tsx b/web/src/components/OAuthProvidersCard.tsx index 6877207f8de..987f4c0eeef 100644 --- a/web/src/components/OAuthProvidersCard.tsx +++ b/web/src/components/OAuthProvidersCard.tsx @@ -20,6 +20,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Badge } from "@nous-research/ui/ui/components/badge"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { OAuthLoginModal } from "@/components/OAuthLoginModal"; import { useI18n } from "@/i18n"; @@ -55,6 +56,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { const [loading, setLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [loginFor, setLoginFor] = useState(null); + const [disconnectTarget, setDisconnectTarget] = + useState(null); const { t } = useI18n(); const onErrorRef = useRef(onError); @@ -74,10 +77,8 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) { }, [refresh]); const handleDisconnect = async (provider: OAuthProvider) => { - if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) { - return; - } setBusyId(provider.id); + setDisconnectTarget(null); try { await api.disconnectOAuthProvider(provider.id); onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`); @@ -236,7 +237,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
)} + setConfirmReset(false)} + onConfirm={executeReset} + title={t.config.confirmResetScope.replace( + "{scope}", + isSearching + ? t.config.searchResults + : prettyCategoryName(activeCategory), + )} + description={`This will reset ${ + (isSearching ? searchMatchedFields : activeFields).length + } field(s) to their default values.`} + destructive + confirmLabel={t.config.resetDefaults} + /> ); } diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index e994c96f270..78880adf0bc 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; -import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useState } from "react"; +import { Clock, Pause, Play, Plus, Trash2, X, Zap } from "lucide-react"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; @@ -10,11 +10,13 @@ import type { CronJob } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; import { useToast } from "@/hooks/useToast"; import { useConfirmDelete } from "@/hooks/useConfirmDelete"; +import { useModalBehavior } from "@/hooks/useModalBehavior"; import { Toast } from "@/components/Toast"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useI18n } from "@/i18n"; +import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; function formatTime(iso?: string | null): string { @@ -80,11 +82,18 @@ export default function CronPage() { const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); const { t } = useI18n(); + const { setEnd } = usePageHeader(); - // New job form state + // New job modal state + const [createModalOpen, setCreateModalOpen] = useState(false); const [prompt, setPrompt] = useState(""); const [schedule, setSchedule] = useState(""); const [name, setName] = useState(""); + const closeCreateModal = useCallback(() => setCreateModalOpen(false), []); + const createModalRef = useModalBehavior({ + open: createModalOpen, + onClose: closeCreateModal, + }); const [deliver, setDeliver] = useState("local"); const [creating, setCreating] = useState(false); @@ -118,6 +127,7 @@ export default function CronPage() { setSchedule(""); setName(""); setDeliver("local"); + setCreateModalOpen(false); loadJobs(); } catch (e) { showToast(`${t.config.failedToSave}: ${e}`, "error"); @@ -181,6 +191,22 @@ export default function CronPage() { ), }); + // Put "Create" button in page header + useLayoutEffect(() => { + setEnd( + , + ); + return () => { + setEnd(null); + }; + }, [setEnd, t.common.create, loading]); + if (loading) { return (
@@ -213,86 +239,110 @@ export default function CronPage() { loading={jobDelete.isDeleting} /> - - - - - {t.cron.newJob} - - - -
-
- - setName(e.target.value)} - /> -
+ {/* Create job modal */} + {createModalOpen && ( +
e.target === e.currentTarget && setCreateModalOpen(false)} + role="dialog" + aria-modal="true" + aria-labelledby="create-cron-title" + > +
+ -
- -