portal-mojo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// Scheduled-task editor — the focused dialog that creates and edits a
|
|
2
|
+
// jobs.ScheduledTask, plus the client-side mirror of the model's `_validate`.
|
|
3
|
+
//
|
|
4
|
+
// Why a hand-built controlled form rather than SchemaForm/formModal: the wire
|
|
5
|
+
// shape is not flat. `run_days` is a list of INTS, `run_times` a list of at
|
|
6
|
+
// most two "HH:MM" strings, and `job_config` a type-specific nested dict whose
|
|
7
|
+
// required keys change with `task_type`. The Field language carries scalars and
|
|
8
|
+
// scalar arrays; expressing "two time strings, seven int toggles and a
|
|
9
|
+
// per-type config object" through it would mean a second value pipeline inside
|
|
10
|
+
// the field defs. One controlled state object, one commit path.
|
|
11
|
+
//
|
|
12
|
+
// The validation below is the whole point of the file. `ScheduledTask.save()`
|
|
13
|
+
// calls `_validate()`, which raises a plain Python `ValueError` — django-mojo
|
|
14
|
+
// turns that into a 400 whose body is the raw message. Every rule the model
|
|
15
|
+
// checks is checked here first, in the model's own order and with its own
|
|
16
|
+
// wording, so the common mistakes never become a server round-trip. The server
|
|
17
|
+
// stays authoritative: anything that still reaches it (the per-user cap, an
|
|
18
|
+
// undeclared channel under JOBS_ALLOWED_CHANNELS enforcement) surfaces
|
|
19
|
+
// VERBATIM through the rejecting save path.
|
|
20
|
+
//
|
|
21
|
+
// NOT offered here, deliberately: a "Run now" control. The capability exists
|
|
22
|
+
// only as the owner-scoped assistant tool `_tool_run_scheduled_task_now`;
|
|
23
|
+
// there is no REST route and no publish endpoint to synthesize one. Per the
|
|
24
|
+
// wave-7a precedent an unsupported operation stays ABSENT rather than shipping
|
|
25
|
+
// disabled — the gap is tracked as django-mojo #1309.
|
|
26
|
+
import { useState, type FormEvent, type ReactNode } from 'react';
|
|
27
|
+
import { modal } from '../../ui';
|
|
28
|
+
import {
|
|
29
|
+
NOTIFY_CHANNELS,
|
|
30
|
+
SCHEDULED_TASK_TYPE_OPTIONS,
|
|
31
|
+
WEEKDAY_LABELS,
|
|
32
|
+
type ScheduledTaskRow,
|
|
33
|
+
type ScheduledTaskType,
|
|
34
|
+
} from './models';
|
|
35
|
+
|
|
36
|
+
// ── The draft ─────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/** Every field a caller may write. `id`, `user`, `run_count`, `last_run` and
|
|
39
|
+
* `last_error` are server-owned and never appear here — the backend stamps
|
|
40
|
+
* `user` from the request, so there is no arbitrary-owner write to express. */
|
|
41
|
+
export interface ScheduledTaskDraft {
|
|
42
|
+
name: string;
|
|
43
|
+
description: string;
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
run_once: boolean;
|
|
46
|
+
task_type: ScheduledTaskType;
|
|
47
|
+
run_times: string[];
|
|
48
|
+
run_days: number[];
|
|
49
|
+
job_config: Record<string, unknown>;
|
|
50
|
+
notify: string[];
|
|
51
|
+
channel: string;
|
|
52
|
+
max_retries: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Model defaults: `enabled=True`, `channel="default"`, everything else empty. */
|
|
56
|
+
export function emptyScheduledTaskDraft(): ScheduledTaskDraft {
|
|
57
|
+
return {
|
|
58
|
+
name: '', description: '', enabled: true, run_once: false,
|
|
59
|
+
task_type: 'llm', run_times: [], run_days: [], job_config: {},
|
|
60
|
+
notify: [], channel: 'default', max_retries: 0,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function scheduledTaskDraft(task: ScheduledTaskRow): ScheduledTaskDraft {
|
|
65
|
+
return {
|
|
66
|
+
name: task.name ?? '',
|
|
67
|
+
description: task.description ?? '',
|
|
68
|
+
enabled: Boolean(task.enabled),
|
|
69
|
+
run_once: Boolean(task.run_once),
|
|
70
|
+
task_type: task.task_type,
|
|
71
|
+
run_times: [...(task.run_times ?? [])],
|
|
72
|
+
run_days: [...(task.run_days ?? [])],
|
|
73
|
+
job_config: { ...(task.job_config ?? {}) },
|
|
74
|
+
notify: [...(task.notify ?? [])],
|
|
75
|
+
channel: task.channel ?? 'default',
|
|
76
|
+
max_retries: Number(task.max_retries ?? 0),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Validation (ScheduledTask._validate, rule for rule) ───────────────
|
|
81
|
+
|
|
82
|
+
const TASK_TYPES = new Set<string>(SCHEDULED_TASK_TYPE_OPTIONS.map((option) => option.value));
|
|
83
|
+
/** `mojo.apps.jobs.CHANNEL_NAME_RE` verbatim. */
|
|
84
|
+
const CHANNEL_NAME_RE = /^[A-Za-z0-9_.\-]{1,100}$/;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The model's messages, in the model's order. Returns [] when the draft would
|
|
88
|
+
* save. A non-empty result is shown to the operator instead of being sent.
|
|
89
|
+
*
|
|
90
|
+
* The three `job_config` requirements at the end are NOT from `_validate` —
|
|
91
|
+
* they are raised by `mojo/apps/jobs/asyncjobs.py` at dispatch time, hours
|
|
92
|
+
* later, and land in a TaskResult nobody is watching. A task that can only
|
|
93
|
+
* ever fail is not worth saving, so they are checked here too.
|
|
94
|
+
*/
|
|
95
|
+
export function validateScheduledTaskDraft(draft: ScheduledTaskDraft): string[] {
|
|
96
|
+
const errors: string[] = [];
|
|
97
|
+
|
|
98
|
+
if (!draft.name.trim()) errors.push('Name is required.');
|
|
99
|
+
|
|
100
|
+
// run_times — max 2, each exactly "HH:MM".
|
|
101
|
+
if (draft.run_times.length > 2) {
|
|
102
|
+
errors.push('run_times cannot have more than 2 entries');
|
|
103
|
+
}
|
|
104
|
+
for (const time of draft.run_times) {
|
|
105
|
+
if (typeof time !== 'string' || time.length !== 5 || time[2] !== ':') {
|
|
106
|
+
errors.push(`Invalid time format: ${time}. Use HH:MM`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const hour = Number(time.slice(0, 2));
|
|
110
|
+
const minute = Number(time.slice(3));
|
|
111
|
+
if (!Number.isInteger(hour) || !Number.isInteger(minute)
|
|
112
|
+
|| hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
113
|
+
errors.push(`Invalid time value: ${time}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// A task with no times never matches an hour and so never runs — the model
|
|
117
|
+
// permits it, the dispatcher silently skips it forever.
|
|
118
|
+
if (draft.run_times.length === 0) {
|
|
119
|
+
errors.push('Set at least one run time — a task with no times never matches an hour and never runs.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// run_days — weekday ints, Mon=0.
|
|
123
|
+
for (const day of draft.run_days) {
|
|
124
|
+
if (!Number.isInteger(day) || day < 0 || day > 6) {
|
|
125
|
+
errors.push(`Invalid weekday: ${day}. Must be 0-6 (Mon=0)`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!TASK_TYPES.has(draft.task_type)) errors.push(`Invalid task_type: ${draft.task_type}`);
|
|
130
|
+
|
|
131
|
+
// channel — charset only; membership (JOBS_ALLOWED_CHANNELS) is a
|
|
132
|
+
// deployment setting this client cannot see, so that one is left to the
|
|
133
|
+
// server and surfaced verbatim when it rejects.
|
|
134
|
+
const channel = draft.channel.trim();
|
|
135
|
+
if (!channel) {
|
|
136
|
+
errors.push("Job channel must be a non-empty string, got ''");
|
|
137
|
+
} else if (!CHANNEL_NAME_RE.test(channel)) {
|
|
138
|
+
errors.push(`Invalid job channel '${channel}': use only letters, digits, '_', '.' and '-' (max 100 chars).`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (const value of draft.notify) {
|
|
142
|
+
if (!NOTIFY_CHANNELS.includes(value as (typeof NOTIFY_CHANNELS)[number])) {
|
|
143
|
+
errors.push(`Invalid notify channel: ${value}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!Number.isInteger(draft.max_retries) || draft.max_retries < 0) {
|
|
148
|
+
errors.push('Max retries must be a whole number of 0 or more.');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Dispatch-time requirements (asyncjobs._run_*_task).
|
|
152
|
+
const config = draft.job_config;
|
|
153
|
+
if (draft.task_type === 'llm' && !String(config.user_prompt ?? '').trim()) {
|
|
154
|
+
errors.push('LLM task requires a user_prompt in job_config');
|
|
155
|
+
}
|
|
156
|
+
if (draft.task_type === 'webhook' && !String(config.url ?? '').trim()) {
|
|
157
|
+
errors.push('Webhook task requires a url in job_config');
|
|
158
|
+
}
|
|
159
|
+
if (draft.task_type === 'job' && !String(config.func ?? '').trim()) {
|
|
160
|
+
errors.push('Job task requires a func in job_config');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return errors;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The wire body for a save. Server-owned fields are never included. */
|
|
167
|
+
export function scheduledTaskChanges(draft: ScheduledTaskDraft): Record<string, unknown> {
|
|
168
|
+
return {
|
|
169
|
+
name: draft.name.trim(),
|
|
170
|
+
description: draft.description,
|
|
171
|
+
enabled: draft.enabled,
|
|
172
|
+
run_once: draft.run_once,
|
|
173
|
+
task_type: draft.task_type,
|
|
174
|
+
run_times: draft.run_times,
|
|
175
|
+
run_days: [...draft.run_days].sort((a, b) => a - b),
|
|
176
|
+
job_config: draft.job_config,
|
|
177
|
+
notify: draft.notify,
|
|
178
|
+
channel: draft.channel.trim(),
|
|
179
|
+
max_retries: draft.max_retries,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Small controlled inputs ───────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function Field({ label, help, children }: { label: string; help?: ReactNode; children: ReactNode }) {
|
|
186
|
+
return (
|
|
187
|
+
<label className="jobs-field">
|
|
188
|
+
<span>{label}</span>
|
|
189
|
+
{children}
|
|
190
|
+
{help && <small className="field-help">{help}</small>}
|
|
191
|
+
</label>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function Toggle({ label, checked, onChange, help }: {
|
|
196
|
+
label: string;
|
|
197
|
+
checked: boolean;
|
|
198
|
+
onChange: (value: boolean) => void;
|
|
199
|
+
help?: string;
|
|
200
|
+
}) {
|
|
201
|
+
return (
|
|
202
|
+
<label className="jobs-task-toggle">
|
|
203
|
+
<input type="checkbox" checked={checked} onChange={(event) => onChange(event.target.checked)} />
|
|
204
|
+
<span>
|
|
205
|
+
{label}
|
|
206
|
+
{help && <small className="dim"> — {help}</small>}
|
|
207
|
+
</span>
|
|
208
|
+
</label>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The `job_config` editor. `payload` (job) and `data` (webhook) are free-form
|
|
214
|
+
* dicts, so they are edited as JSON text with a parse guard — an unparsable
|
|
215
|
+
* blob keeps the last good value and shows an inline error rather than
|
|
216
|
+
* silently writing a string where the backend wants an object.
|
|
217
|
+
*/
|
|
218
|
+
function ConfigEditor({ draft, patch }: {
|
|
219
|
+
draft: ScheduledTaskDraft;
|
|
220
|
+
patch: (config: Record<string, unknown>) => void;
|
|
221
|
+
}) {
|
|
222
|
+
const config = draft.job_config;
|
|
223
|
+
const [jsonText, setJsonText] = useState(() => {
|
|
224
|
+
const blob = draft.task_type === 'webhook' ? config.data : config.payload;
|
|
225
|
+
return JSON.stringify(blob ?? {}, null, 2);
|
|
226
|
+
});
|
|
227
|
+
const [jsonError, setJsonError] = useState<string | null>(null);
|
|
228
|
+
|
|
229
|
+
const setBlob = (key: 'payload' | 'data', text: string) => {
|
|
230
|
+
setJsonText(text);
|
|
231
|
+
try {
|
|
232
|
+
const parsed: unknown = text.trim() ? JSON.parse(text) : {};
|
|
233
|
+
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
234
|
+
setJsonError('Must be a JSON object, e.g. {"key": "value"}.');
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
setJsonError(null);
|
|
238
|
+
patch({ ...config, [key]: parsed as Record<string, unknown> });
|
|
239
|
+
} catch (error) {
|
|
240
|
+
setJsonError(error instanceof Error ? error.message : 'Invalid JSON');
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
if (draft.task_type === 'llm') {
|
|
245
|
+
return (
|
|
246
|
+
<>
|
|
247
|
+
<Field label="System prompt" help="Optional. Sets the assistant’s role for every run.">
|
|
248
|
+
<textarea
|
|
249
|
+
className="input" rows={3}
|
|
250
|
+
value={String(config.system_prompt ?? '')}
|
|
251
|
+
onChange={(event) => patch({ ...config, system_prompt: event.target.value })}
|
|
252
|
+
/>
|
|
253
|
+
</Field>
|
|
254
|
+
<Field label="User prompt" help="Required. The prompt run on schedule; its answer becomes the task result.">
|
|
255
|
+
<textarea
|
|
256
|
+
className={`input${String(config.user_prompt ?? '').trim() ? '' : ' input-invalid'}`}
|
|
257
|
+
rows={4}
|
|
258
|
+
value={String(config.user_prompt ?? '')}
|
|
259
|
+
onChange={(event) => patch({ ...config, user_prompt: event.target.value })}
|
|
260
|
+
/>
|
|
261
|
+
</Field>
|
|
262
|
+
</>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (draft.task_type === 'webhook') {
|
|
267
|
+
return (
|
|
268
|
+
<>
|
|
269
|
+
<Field label="URL" help="Required. Each run publishes a webhook job that POSTs to this URL.">
|
|
270
|
+
<input
|
|
271
|
+
type="url" className={`input${String(config.url ?? '').trim() ? '' : ' input-invalid'}`}
|
|
272
|
+
placeholder="https://example.com/hooks/mojo"
|
|
273
|
+
value={String(config.url ?? '')}
|
|
274
|
+
onChange={(event) => patch({ ...config, url: event.target.value })}
|
|
275
|
+
/>
|
|
276
|
+
</Field>
|
|
277
|
+
<Field label="Body (JSON)" help={jsonError ? <span className="jobs-text-danger">{jsonError}</span> : 'Sent as the POST body.'}>
|
|
278
|
+
<textarea
|
|
279
|
+
className={`input jobs-task-code${jsonError ? ' input-invalid' : ''}`} rows={5}
|
|
280
|
+
value={jsonText}
|
|
281
|
+
onChange={(event) => setBlob('data', event.target.value)}
|
|
282
|
+
/>
|
|
283
|
+
</Field>
|
|
284
|
+
</>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<>
|
|
290
|
+
<Field label="Function" help="Required. The dotted path the published job runs.">
|
|
291
|
+
<input
|
|
292
|
+
type="text" className={`input${String(config.func ?? '').trim() ? '' : ' input-invalid'}`}
|
|
293
|
+
placeholder="mojo.apps.jobs.examples.sample_jobs.generate_report"
|
|
294
|
+
value={String(config.func ?? '')}
|
|
295
|
+
onChange={(event) => patch({ ...config, func: event.target.value })}
|
|
296
|
+
/>
|
|
297
|
+
</Field>
|
|
298
|
+
<Field label="Payload (JSON)" help={jsonError ? <span className="jobs-text-danger">{jsonError}</span> : 'Passed to the function on every run.'}>
|
|
299
|
+
<textarea
|
|
300
|
+
className={`input jobs-task-code${jsonError ? ' input-invalid' : ''}`} rows={5}
|
|
301
|
+
value={jsonText}
|
|
302
|
+
onChange={(event) => setBlob('payload', event.target.value)}
|
|
303
|
+
/>
|
|
304
|
+
</Field>
|
|
305
|
+
</>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── The dialog ────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
function ScheduledTaskEditor({ task, submit, onDone }: {
|
|
312
|
+
task?: ScheduledTaskRow;
|
|
313
|
+
submit: (changes: Record<string, unknown>) => Promise<unknown>;
|
|
314
|
+
onDone: (saved: boolean) => void;
|
|
315
|
+
}) {
|
|
316
|
+
const editing = task != null;
|
|
317
|
+
const [draft, setDraft] = useState<ScheduledTaskDraft>(() => (task ? scheduledTaskDraft(task) : emptyScheduledTaskDraft()));
|
|
318
|
+
const [attempted, setAttempted] = useState(false);
|
|
319
|
+
const [serverError, setServerError] = useState<string | null>(null);
|
|
320
|
+
const [busy, setBusy] = useState(false);
|
|
321
|
+
|
|
322
|
+
const patch = (changes: Partial<ScheduledTaskDraft>) => setDraft((prev) => ({ ...prev, ...changes }));
|
|
323
|
+
const errors = validateScheduledTaskDraft(draft);
|
|
324
|
+
|
|
325
|
+
// Times are two independent slots because the model caps run_times at two.
|
|
326
|
+
// Editing them as one comma-separated string (the source's approach) is
|
|
327
|
+
// what produced "Invalid time format" 400s in the first place.
|
|
328
|
+
const setTime = (index: 0 | 1, value: string) => {
|
|
329
|
+
const next = [draft.run_times[0] ?? '', draft.run_times[1] ?? ''];
|
|
330
|
+
next[index] = value.trim();
|
|
331
|
+
patch({ run_times: next.filter((time) => time !== '') });
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const toggleDay = (day: number) => {
|
|
335
|
+
const has = draft.run_days.includes(day);
|
|
336
|
+
patch({ run_days: has ? draft.run_days.filter((d) => d !== day) : [...draft.run_days, day].sort((a, b) => a - b) });
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const toggleNotify = (channel: string) => {
|
|
340
|
+
const has = draft.notify.includes(channel);
|
|
341
|
+
patch({ notify: has ? draft.notify.filter((value) => value !== channel) : [...draft.notify, channel] });
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const onSubmit = async (event: FormEvent) => {
|
|
345
|
+
event.preventDefault();
|
|
346
|
+
setAttempted(true);
|
|
347
|
+
setServerError(null);
|
|
348
|
+
if (errors.length > 0) return;
|
|
349
|
+
setBusy(true);
|
|
350
|
+
try {
|
|
351
|
+
await submit(scheduledTaskChanges(draft));
|
|
352
|
+
onDone(true);
|
|
353
|
+
} catch (error) {
|
|
354
|
+
// The rejecting save path is the safety net: the per-user cap and
|
|
355
|
+
// an undeclared channel are only knowable server-side, and their
|
|
356
|
+
// text is shown exactly as the backend wrote it.
|
|
357
|
+
setServerError(error instanceof Error ? error.message : 'Save failed');
|
|
358
|
+
} finally {
|
|
359
|
+
setBusy(false);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<form className="modal-pad jobs-task-form" onSubmit={(event) => void onSubmit(event)}>
|
|
365
|
+
<h2 className="modal-title">{editing ? `Edit ${task.name || 'scheduled task'}` : 'New scheduled task'}</h2>
|
|
366
|
+
|
|
367
|
+
{!editing && (
|
|
368
|
+
<p className="jobs-note jobs-note-info">
|
|
369
|
+
<i className="bi bi-person-badge" />
|
|
370
|
+
The task is created under <strong>your</strong> account — the backend stamps the owner from the
|
|
371
|
+
request, so a task cannot be filed on someone else’s behalf.
|
|
372
|
+
</p>
|
|
373
|
+
)}
|
|
374
|
+
{serverError && <div className="form-alert">{serverError}</div>}
|
|
375
|
+
{attempted && errors.length > 0 && (
|
|
376
|
+
<div className="form-alert">
|
|
377
|
+
<ul className="jobs-task-errors">
|
|
378
|
+
{errors.map((message) => <li key={message}>{message}</li>)}
|
|
379
|
+
</ul>
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
|
|
383
|
+
<Field label="Name"><input type="text" className="input" value={draft.name} placeholder="Daily revenue digest" onChange={(event) => patch({ name: event.target.value })} /></Field>
|
|
384
|
+
<Field label="Description">
|
|
385
|
+
<textarea className="input" rows={2} value={draft.description} placeholder="What this task does…" onChange={(event) => patch({ description: event.target.value })} />
|
|
386
|
+
</Field>
|
|
387
|
+
|
|
388
|
+
<Field
|
|
389
|
+
label="Task type"
|
|
390
|
+
help={editing
|
|
391
|
+
? 'Fixed after creation — the backend merges JSON bodies into job_config, so switching types would leave the old type’s keys behind.'
|
|
392
|
+
: 'Decides which configuration the task carries.'}
|
|
393
|
+
>
|
|
394
|
+
<select
|
|
395
|
+
className="input" value={draft.task_type} disabled={editing}
|
|
396
|
+
onChange={(event) => patch({ task_type: event.target.value as ScheduledTaskType, job_config: {} })}
|
|
397
|
+
>
|
|
398
|
+
{SCHEDULED_TASK_TYPE_OPTIONS.map((option) => (
|
|
399
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
400
|
+
))}
|
|
401
|
+
</select>
|
|
402
|
+
</Field>
|
|
403
|
+
|
|
404
|
+
<div className="jobs-task-toggles">
|
|
405
|
+
<Toggle label="Enabled" checked={draft.enabled} onChange={(enabled) => patch({ enabled })} help="only enabled tasks are dispatched" />
|
|
406
|
+
<Toggle label="Run once" checked={draft.run_once} onChange={(run_once) => patch({ run_once })} help="disables itself after one successful run" />
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
<div className="jobs-task-times">
|
|
410
|
+
<Field label="Run time" help="24-hour HH:MM, in the owner’s organization timezone.">
|
|
411
|
+
<input type="text" className="input" maxLength={5} placeholder="09:00" value={draft.run_times[0] ?? ''} onChange={(event) => setTime(0, event.target.value)} />
|
|
412
|
+
</Field>
|
|
413
|
+
<Field label="Second run time" help="Optional. The model allows at most two.">
|
|
414
|
+
<input type="text" className="input" maxLength={5} placeholder="17:00" value={draft.run_times[1] ?? ''} onChange={(event) => setTime(1, event.target.value)} />
|
|
415
|
+
</Field>
|
|
416
|
+
</div>
|
|
417
|
+
|
|
418
|
+
<Field label="Run days" help={draft.run_days.length === 0 ? 'None selected — runs every day.' : 'Monday is 0, matching Python’s weekday().'}>
|
|
419
|
+
<div className="seg jobs-task-days">
|
|
420
|
+
{WEEKDAY_LABELS.map((label, day) => (
|
|
421
|
+
<button
|
|
422
|
+
key={label} type="button"
|
|
423
|
+
className={`seg-btn${draft.run_days.includes(day) ? ' seg-active' : ''}`}
|
|
424
|
+
aria-pressed={draft.run_days.includes(day)}
|
|
425
|
+
onClick={() => toggleDay(day)}
|
|
426
|
+
>
|
|
427
|
+
{label}
|
|
428
|
+
</button>
|
|
429
|
+
))}
|
|
430
|
+
</div>
|
|
431
|
+
</Field>
|
|
432
|
+
|
|
433
|
+
<Field label="Notify" help="Opt-in channels notified after a successful run.">
|
|
434
|
+
<div className="jobs-task-checks">
|
|
435
|
+
{NOTIFY_CHANNELS.map((channel) => (
|
|
436
|
+
<label key={channel} className="jobs-task-toggle">
|
|
437
|
+
<input type="checkbox" checked={draft.notify.includes(channel)} onChange={() => toggleNotify(channel)} />
|
|
438
|
+
<span>{channel}</span>
|
|
439
|
+
</label>
|
|
440
|
+
))}
|
|
441
|
+
</div>
|
|
442
|
+
</Field>
|
|
443
|
+
|
|
444
|
+
<div className="jobs-task-times">
|
|
445
|
+
<Field label="Channel" help="The job channel published runs target.">
|
|
446
|
+
<input type="text" className="input" value={draft.channel} placeholder="default" onChange={(event) => patch({ channel: event.target.value })} />
|
|
447
|
+
</Field>
|
|
448
|
+
<Field label="Max retries">
|
|
449
|
+
<input
|
|
450
|
+
type="number" className="input" min={0} step={1} value={draft.max_retries}
|
|
451
|
+
onChange={(event) => patch({ max_retries: Math.trunc(Number(event.target.value) || 0) })}
|
|
452
|
+
/>
|
|
453
|
+
</Field>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<div className="jobs-task-config">
|
|
457
|
+
{/* Keyed on the type so switching it remounts the editor —
|
|
458
|
+
the JSON textarea holds its own parsed-text state. */}
|
|
459
|
+
<ConfigEditor key={draft.task_type} draft={draft} patch={(job_config) => patch({ job_config })} />
|
|
460
|
+
<p className="jobs-note jobs-note-info">
|
|
461
|
+
<i className="bi bi-info-circle" />
|
|
462
|
+
Configuration is <strong>merged</strong> server-side: a value you change is written, but a key you
|
|
463
|
+
delete here stays stored.
|
|
464
|
+
</p>
|
|
465
|
+
</div>
|
|
466
|
+
|
|
467
|
+
<div className="modal-actions">
|
|
468
|
+
<button type="button" className="btn" disabled={busy} onClick={() => onDone(false)}>Cancel</button>
|
|
469
|
+
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
470
|
+
{busy ? 'Saving…' : editing ? 'Save changes' : 'Create task'}
|
|
471
|
+
</button>
|
|
472
|
+
</div>
|
|
473
|
+
</form>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Open the editor as a focused dialog. Stacks above `modal.detail` on the same
|
|
479
|
+
* native `<dialog>` substrate. Resolves true once a save succeeded.
|
|
480
|
+
*
|
|
481
|
+
* `submit` belongs to the caller so the mutation (and its cache invalidation)
|
|
482
|
+
* stays where the data lives — the dialog only collects and validates.
|
|
483
|
+
*/
|
|
484
|
+
export function openScheduledTaskEditor(args: {
|
|
485
|
+
task?: ScheduledTaskRow;
|
|
486
|
+
submit: (changes: Record<string, unknown>) => Promise<unknown>;
|
|
487
|
+
}): Promise<boolean> {
|
|
488
|
+
return modal
|
|
489
|
+
.open<boolean>((close) => (
|
|
490
|
+
<ScheduledTaskEditor task={args.task} submit={args.submit} onDone={(saved) => close(saved)} />
|
|
491
|
+
))
|
|
492
|
+
.then((value) => value === true);
|
|
493
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// JobChannelsPanel — per-channel depth and health, plus the scheduler card.
|
|
2
|
+
// Rebuilt from web-mojo JobHealthView.js's channel logic, which is the one
|
|
3
|
+
// part of that view worth keeping: its severity thresholds. The view itself is
|
|
4
|
+
// not ported — it read `/api/jobs/health`, which raises server-side.
|
|
5
|
+
import { ArmedButton, Badge, fmt, toast } from '../../../ui';
|
|
6
|
+
import { useCan } from '../../../client';
|
|
7
|
+
import { forceSchedulerLead } from '../control';
|
|
8
|
+
import {
|
|
9
|
+
JOBS_MANAGE_PERMS,
|
|
10
|
+
channelSeverity,
|
|
11
|
+
channelSeverityTone,
|
|
12
|
+
type JobChannelState,
|
|
13
|
+
type JobsStats,
|
|
14
|
+
} from '../models';
|
|
15
|
+
|
|
16
|
+
const SEVERITY_LABEL = { healthy: 'Healthy', warning: 'Backlog', critical: 'Critical' } as const;
|
|
17
|
+
|
|
18
|
+
export function JobChannelsPanel({ stats, isPending, onSelectChannel, onSchedulerChanged }: {
|
|
19
|
+
stats: JobsStats | undefined;
|
|
20
|
+
isPending: boolean;
|
|
21
|
+
/** Row action — open the jobs table already filtered to this channel. */
|
|
22
|
+
onSelectChannel?: (channel: string) => void;
|
|
23
|
+
onSchedulerChanged?: () => void;
|
|
24
|
+
}) {
|
|
25
|
+
const { can: canManage } = useCan(JOBS_MANAGE_PERMS);
|
|
26
|
+
const channels: [string, JobChannelState][] = Object.entries(stats?.channels ?? {});
|
|
27
|
+
const scheduler = stats?.scheduler;
|
|
28
|
+
|
|
29
|
+
const releaseLock = async () => {
|
|
30
|
+
try {
|
|
31
|
+
const result = await forceSchedulerLead();
|
|
32
|
+
toast.success(result.previous_holder
|
|
33
|
+
? `Scheduler lock released (was held by ${result.previous_holder})`
|
|
34
|
+
: result.message);
|
|
35
|
+
onSchedulerChanged?.();
|
|
36
|
+
} catch (error) {
|
|
37
|
+
toast.error(error instanceof Error ? error.message : 'Could not release the scheduler lock');
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="jobs-channels-grid">
|
|
43
|
+
<div className="panel jobs-channels-panel">
|
|
44
|
+
<div className="toolbar">
|
|
45
|
+
<div className="toolbar-heading">
|
|
46
|
+
<div className="eyebrow">Queues</div>
|
|
47
|
+
<h2 className="panel-title">Channels</h2>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
<div className="tbl-scroll">
|
|
51
|
+
<table className="tbl">
|
|
52
|
+
<thead>
|
|
53
|
+
<tr>
|
|
54
|
+
<th>Channel</th>
|
|
55
|
+
<th>Queued</th>
|
|
56
|
+
<th>In flight</th>
|
|
57
|
+
<th>Scheduled</th>
|
|
58
|
+
<th>DB running</th>
|
|
59
|
+
<th>Runners</th>
|
|
60
|
+
<th>State</th>
|
|
61
|
+
</tr>
|
|
62
|
+
</thead>
|
|
63
|
+
<tbody>
|
|
64
|
+
{isPending && <tr><td colSpan={7}><div className="empty-state"><p>Loading channels…</p></div></td></tr>}
|
|
65
|
+
{!isPending && channels.length === 0 && (
|
|
66
|
+
<tr><td colSpan={7}>
|
|
67
|
+
<div className="empty-state">
|
|
68
|
+
<i className="bi bi-broadcast" />
|
|
69
|
+
<h3>No channels configured</h3>
|
|
70
|
+
<p>Nothing is declared in <code>JOBS_CHANNELS</code> and no stream has been registered.</p>
|
|
71
|
+
</div>
|
|
72
|
+
</td></tr>
|
|
73
|
+
)}
|
|
74
|
+
{channels.map(([name, state]) => {
|
|
75
|
+
const severity = channelSeverity(state);
|
|
76
|
+
const tone = channelSeverityTone(severity);
|
|
77
|
+
return (
|
|
78
|
+
<tr
|
|
79
|
+
key={name}
|
|
80
|
+
className={onSelectChannel ? 'row-click' : undefined}
|
|
81
|
+
onClick={onSelectChannel ? () => onSelectChannel(name) : undefined}
|
|
82
|
+
>
|
|
83
|
+
<td><code>{name}</code></td>
|
|
84
|
+
<td>{fmt.number(state.queued_count)}</td>
|
|
85
|
+
<td>{fmt.number(state.inflight_count)}</td>
|
|
86
|
+
<td>{fmt.number(state.scheduled_count)}</td>
|
|
87
|
+
<td>{fmt.number(state.db_running)}</td>
|
|
88
|
+
<td>
|
|
89
|
+
{state.runners === 0
|
|
90
|
+
? <span className="jobs-text-danger">0</span>
|
|
91
|
+
: fmt.number(state.runners)}
|
|
92
|
+
</td>
|
|
93
|
+
<td>
|
|
94
|
+
<Badge tone={tone}>{SEVERITY_LABEL[severity]}</Badge>
|
|
95
|
+
{state.runners === 0 && (
|
|
96
|
+
<div className="dim jobs-channel-note">No alive runner will drain this channel.</div>
|
|
97
|
+
)}
|
|
98
|
+
</td>
|
|
99
|
+
</tr>
|
|
100
|
+
);
|
|
101
|
+
})}
|
|
102
|
+
</tbody>
|
|
103
|
+
</table>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div className="panel jobs-scheduler-card">
|
|
108
|
+
<div className="toolbar">
|
|
109
|
+
<div className="toolbar-heading">
|
|
110
|
+
<div className="eyebrow">Dispatcher</div>
|
|
111
|
+
<h2 className="panel-title">Scheduler</h2>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="jobs-scheduler-body">
|
|
115
|
+
<div className="jobs-scheduler-state">
|
|
116
|
+
<span className={`jobs-dot jobs-dot-${scheduler?.active ? 'success' : 'danger'}`} />
|
|
117
|
+
<strong>{isPending ? 'Loading…' : scheduler?.active ? 'Running' : 'Stopped'}</strong>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="dim">
|
|
120
|
+
{scheduler?.lock_holder
|
|
121
|
+
? <>Lock held by <code>{scheduler.lock_holder}</code></>
|
|
122
|
+
: 'No scheduler lock is held. Delayed and scheduled jobs will not be dispatched.'}
|
|
123
|
+
</div>
|
|
124
|
+
{/* Releasing the lock while a healthy leader holds it can
|
|
125
|
+
let two schedulers dispatch the same due jobs, so it is
|
|
126
|
+
armed even though it looks like a small button. */}
|
|
127
|
+
<ArmedButton
|
|
128
|
+
className="btn-compact"
|
|
129
|
+
icon="bi-key"
|
|
130
|
+
label="Force scheduler lead"
|
|
131
|
+
armedLabel="Click again to release the lock"
|
|
132
|
+
disabled={!canManage}
|
|
133
|
+
onConfirm={releaseLock}
|
|
134
|
+
/>
|
|
135
|
+
<p className="dim jobs-scheduler-hint">
|
|
136
|
+
Deletes the Redis lock so another process can take leadership. Only safe when the current leader is stuck.
|
|
137
|
+
</p>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|