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,423 @@
|
|
|
1
|
+
// JobOperationsSection — the queue control plane. Port of web-mojo
|
|
2
|
+
// sections/JobOperationsSection.js, re-wired to what django-mojo serves.
|
|
3
|
+
//
|
|
4
|
+
// Corrections to the source, all of them consequences of reading the backend:
|
|
5
|
+
// · "All Channels" no longer sends `channel: null` (an instant HTTP 400 —
|
|
6
|
+
// clear-stuck and manual-reclaim are @requires_params('channel')). It is a
|
|
7
|
+
// CLIENT fan-out with per-channel results.
|
|
8
|
+
// · results are read from the keys the handlers actually return (`cleared`,
|
|
9
|
+
// `deleted`, `reset_count`) instead of the `data.count` the source read,
|
|
10
|
+
// which never existed and always reported zero.
|
|
11
|
+
// · purge is dry-run-first: the dialog previews "N jobs would be deleted
|
|
12
|
+
// before <cutoff>" and only then offers the armed real run.
|
|
13
|
+
// · clear-queue's `confirm:"yes"` is sent only AFTER the armed confirmation.
|
|
14
|
+
// Pre-satisfying a server-side safety gate removes it.
|
|
15
|
+
// · every destructive control is an ArmedButton, not a plain button.
|
|
16
|
+
import { useState, type ReactNode } from 'react';
|
|
17
|
+
import { ArmedButton, JsonBlock, modal, toast } from '../../../ui';
|
|
18
|
+
import { useCan } from '../../../client';
|
|
19
|
+
import {
|
|
20
|
+
cleanupConsumers,
|
|
21
|
+
clearQueue,
|
|
22
|
+
clearStuck,
|
|
23
|
+
forEachChannel,
|
|
24
|
+
isPurgeDryRun,
|
|
25
|
+
manualReclaim,
|
|
26
|
+
publishTestJob,
|
|
27
|
+
publishTestSuite,
|
|
28
|
+
purgeJobs,
|
|
29
|
+
rebuildScheduled,
|
|
30
|
+
resetFailed,
|
|
31
|
+
summarizeChannelOutcomes,
|
|
32
|
+
type PurgeResult,
|
|
33
|
+
} from '../control';
|
|
34
|
+
import { JOBS_MANAGE_PERMS, JOB_STATUS_OPTIONS } from '../models';
|
|
35
|
+
|
|
36
|
+
const ALL_CHANNELS = '';
|
|
37
|
+
|
|
38
|
+
function OperationRow({ label, icon, description, children, note }: {
|
|
39
|
+
label: string;
|
|
40
|
+
icon: string;
|
|
41
|
+
description: ReactNode;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
note?: ReactNode;
|
|
44
|
+
}) {
|
|
45
|
+
return (
|
|
46
|
+
<div className="jobs-op-row">
|
|
47
|
+
<div className="jobs-op-label"><i className={`bi ${icon}`} /> {label}</div>
|
|
48
|
+
<div className="jobs-op-desc">
|
|
49
|
+
{description}
|
|
50
|
+
{note && <div className="jobs-op-note">{note}</div>}
|
|
51
|
+
</div>
|
|
52
|
+
<div className="jobs-op-action">{children}</div>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Purge dialog: preview, then arm ───────────────────────────────────
|
|
58
|
+
|
|
59
|
+
function PurgeDialog({ onClose }: { onClose: () => void }) {
|
|
60
|
+
const [days, setDays] = useState(30);
|
|
61
|
+
const [status, setStatus] = useState('');
|
|
62
|
+
const [preview, setPreview] = useState<PurgeResult | null>(null);
|
|
63
|
+
const [busy, setBusy] = useState(false);
|
|
64
|
+
|
|
65
|
+
const runPreview = async () => {
|
|
66
|
+
setBusy(true);
|
|
67
|
+
setPreview(null);
|
|
68
|
+
try {
|
|
69
|
+
const result = await purgeJobs({ daysOld: days, status: status || null, dryRun: true });
|
|
70
|
+
setPreview(result);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
toast.error(error instanceof Error ? error.message : 'Purge preview failed');
|
|
73
|
+
} finally {
|
|
74
|
+
setBusy(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const runPurge = async () => {
|
|
79
|
+
setBusy(true);
|
|
80
|
+
try {
|
|
81
|
+
const result = await purgeJobs({ daysOld: days, status: status || null });
|
|
82
|
+
// A REAL run reports `deleted`; only the dry run has `count`.
|
|
83
|
+
const deleted = isPurgeDryRun(result) ? result.count : result.deleted;
|
|
84
|
+
toast.success(`Purged ${deleted} record(s) older than ${days} day(s)`);
|
|
85
|
+
onClose();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
toast.error(error instanceof Error ? error.message : 'Purge failed');
|
|
88
|
+
} finally {
|
|
89
|
+
setBusy(false);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const previewCount = preview && isPurgeDryRun(preview) ? preview.count : null;
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="modal-pad">
|
|
97
|
+
<h2 className="modal-title">Purge old jobs</h2>
|
|
98
|
+
<p className="dim">
|
|
99
|
+
Deletes job rows older than the cutoff, and their events and logs with them. Preview first — the
|
|
100
|
+
count is the only way to know what the cutoff actually covers.
|
|
101
|
+
</p>
|
|
102
|
+
<label className="jobs-field">
|
|
103
|
+
<span>Older than (days)</span>
|
|
104
|
+
<input
|
|
105
|
+
type="number" className="input input-compact" min={0} step={1}
|
|
106
|
+
value={days}
|
|
107
|
+
onChange={(event) => { setDays(Math.max(0, Number(event.target.value) || 0)); setPreview(null); }}
|
|
108
|
+
/>
|
|
109
|
+
</label>
|
|
110
|
+
<label className="jobs-field">
|
|
111
|
+
<span>Status filter</span>
|
|
112
|
+
<select
|
|
113
|
+
className="input input-compact"
|
|
114
|
+
value={status}
|
|
115
|
+
onChange={(event) => { setStatus(event.target.value); setPreview(null); }}
|
|
116
|
+
>
|
|
117
|
+
<option value="">Every status</option>
|
|
118
|
+
{JOB_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
119
|
+
</select>
|
|
120
|
+
</label>
|
|
121
|
+
|
|
122
|
+
{preview && previewCount != null && (
|
|
123
|
+
<p className={`jobs-note ${previewCount > 0 ? 'jobs-note-warn' : 'jobs-note-info'}`}>
|
|
124
|
+
<i className="bi bi-info-circle" />
|
|
125
|
+
{previewCount > 0
|
|
126
|
+
? <> <strong>{previewCount}</strong> job(s) would be deleted, created before {new Date(preview.cutoff).toLocaleString()}.</>
|
|
127
|
+
: <> Nothing matches — no job is older than that cutoff.</>}
|
|
128
|
+
</p>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
<div className="modal-actions">
|
|
132
|
+
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
|
133
|
+
<button type="button" className="btn" disabled={busy} onClick={() => void runPreview()}>
|
|
134
|
+
{busy && !preview ? 'Previewing…' : 'Preview'}
|
|
135
|
+
</button>
|
|
136
|
+
<ArmedButton
|
|
137
|
+
label="Purge"
|
|
138
|
+
armedLabel={previewCount != null ? `Click again to delete ${previewCount} job(s)` : 'Click again to purge'}
|
|
139
|
+
className="btn-danger-ghost"
|
|
140
|
+
// Arming before a preview would be arming a number nobody
|
|
141
|
+
// has seen.
|
|
142
|
+
disabled={busy || previewCount == null || previewCount === 0}
|
|
143
|
+
onConfirm={runPurge}
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Test-job dialog ───────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function TestJobDialog({ channels, defaultChannel, onClose }: {
|
|
153
|
+
channels: string[];
|
|
154
|
+
defaultChannel: string;
|
|
155
|
+
onClose: () => void;
|
|
156
|
+
}) {
|
|
157
|
+
const [channel, setChannel] = useState(defaultChannel || channels[0] || 'default');
|
|
158
|
+
const [delay, setDelay] = useState(0);
|
|
159
|
+
const [busy, setBusy] = useState(false);
|
|
160
|
+
|
|
161
|
+
const run = async () => {
|
|
162
|
+
setBusy(true);
|
|
163
|
+
try {
|
|
164
|
+
const result = await publishTestJob({ channel, delay: delay > 0 ? delay : null });
|
|
165
|
+
toast.success(`Test job ${result.job_id.slice(0, 12)}… published to ${result.channel}${result.delayed ? ` (in ${delay}s)` : ''}`);
|
|
166
|
+
onClose();
|
|
167
|
+
} catch (error) {
|
|
168
|
+
toast.error(error instanceof Error ? error.message : 'Could not publish the test job');
|
|
169
|
+
} finally {
|
|
170
|
+
setBusy(false);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className="modal-pad">
|
|
176
|
+
<h2 className="modal-title">Publish a test job</h2>
|
|
177
|
+
<p className="dim">Publishes one sample job and hands back its id so it can be followed in the jobs table.</p>
|
|
178
|
+
<label className="jobs-field">
|
|
179
|
+
<span>Channel</span>
|
|
180
|
+
<select className="input input-compact" value={channel} onChange={(event) => setChannel(event.target.value)}>
|
|
181
|
+
{(channels.length > 0 ? channels : ['default']).map((name) => <option key={name} value={name}>{name}</option>)}
|
|
182
|
+
</select>
|
|
183
|
+
</label>
|
|
184
|
+
<label className="jobs-field">
|
|
185
|
+
<span>Delay (seconds, 0 = immediate)</span>
|
|
186
|
+
<input
|
|
187
|
+
type="number" className="input input-compact" min={0} step={5}
|
|
188
|
+
value={delay}
|
|
189
|
+
onChange={(event) => setDelay(Math.max(0, Number(event.target.value) || 0))}
|
|
190
|
+
/>
|
|
191
|
+
</label>
|
|
192
|
+
<div className="modal-actions">
|
|
193
|
+
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
|
194
|
+
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void run()}>
|
|
195
|
+
{busy ? 'Publishing…' : 'Publish'}
|
|
196
|
+
</button>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── The section ───────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export function JobOperationsSection({ channels, channelsPending, onChanged }: {
|
|
205
|
+
channels: string[];
|
|
206
|
+
channelsPending: boolean;
|
|
207
|
+
/** Refresh the dashboard after a control mutates queue state. */
|
|
208
|
+
onChanged?: () => void;
|
|
209
|
+
}) {
|
|
210
|
+
const { can: canManage } = useCan(JOBS_MANAGE_PERMS);
|
|
211
|
+
const [channel, setChannel] = useState(ALL_CHANNELS);
|
|
212
|
+
const noChannels = !channelsPending && channels.length === 0;
|
|
213
|
+
// A stale selection must never become a request for a channel that no
|
|
214
|
+
// longer exists.
|
|
215
|
+
const target = channel && channels.includes(channel) ? channel : ALL_CHANNELS;
|
|
216
|
+
const targets = target ? [target] : channels;
|
|
217
|
+
const disabled = !canManage || noChannels;
|
|
218
|
+
|
|
219
|
+
const after = (message: string) => { toast.success(message); onChanged?.(); };
|
|
220
|
+
const failed = (error: unknown, fallback: string) =>
|
|
221
|
+
toast.error(error instanceof Error ? error.message : fallback);
|
|
222
|
+
|
|
223
|
+
/** clear-stuck / manual-reclaim have no all-channel form — fan out. */
|
|
224
|
+
const runFanOut = async (verb: string, run: (name: string) => Promise<{ cleared: number }>) => {
|
|
225
|
+
try {
|
|
226
|
+
const outcomes = await forEachChannel(targets, run);
|
|
227
|
+
const cleared = outcomes.reduce((sum, outcome) => sum + (outcome.value?.cleared ?? 0), 0);
|
|
228
|
+
const anyFailed = outcomes.some((outcome) => !outcome.ok);
|
|
229
|
+
const line = `${cleared} job(s) requeued · ${summarizeChannelOutcomes(outcomes, verb)}`;
|
|
230
|
+
// A partial fan-out is reported as partial, never as success.
|
|
231
|
+
if (anyFailed) toast.warning(line); else toast.success(line);
|
|
232
|
+
onChanged?.();
|
|
233
|
+
} catch (error) {
|
|
234
|
+
failed(error, `${verb} failed`);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<div className="panel jobs-operations">
|
|
240
|
+
<div className="toolbar">
|
|
241
|
+
<div className="toolbar-heading">
|
|
242
|
+
<div className="eyebrow">Control</div>
|
|
243
|
+
<h2 className="panel-title">Operations</h2>
|
|
244
|
+
</div>
|
|
245
|
+
<div className="toolbar-controls">
|
|
246
|
+
<label className="jobs-inline-field">
|
|
247
|
+
<span className="dim">Target</span>
|
|
248
|
+
<select
|
|
249
|
+
className="input input-compact"
|
|
250
|
+
value={target}
|
|
251
|
+
disabled={noChannels}
|
|
252
|
+
onChange={(event) => setChannel(event.target.value)}
|
|
253
|
+
aria-label="Target channel"
|
|
254
|
+
>
|
|
255
|
+
<option value={ALL_CHANNELS}>All channels</option>
|
|
256
|
+
{channels.map((name) => <option key={name} value={name}>{name}</option>)}
|
|
257
|
+
</select>
|
|
258
|
+
</label>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{noChannels && (
|
|
263
|
+
<p className="jobs-note jobs-note-warn">
|
|
264
|
+
<i className="bi bi-exclamation-triangle" /> No channels are configured or registered. Every
|
|
265
|
+
channel-scoped operation is unavailable until a channel exists.
|
|
266
|
+
</p>
|
|
267
|
+
)}
|
|
268
|
+
{!canManage && (
|
|
269
|
+
<p className="jobs-note jobs-note-info">
|
|
270
|
+
<i className="bi bi-info-circle" /> These controls require a global <code>manage_jobs</code> or <code>jobs</code> grant.
|
|
271
|
+
</p>
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
<OperationRow
|
|
275
|
+
label="Test job" icon="bi-play-circle"
|
|
276
|
+
description="Publish one sample job to verify the pipeline end to end."
|
|
277
|
+
>
|
|
278
|
+
<button
|
|
279
|
+
type="button" className="btn btn-compact" disabled={disabled}
|
|
280
|
+
onClick={() => void modal.open((close) => (
|
|
281
|
+
<TestJobDialog channels={channels} defaultChannel={target} onClose={() => { close(null); onChanged?.(); }} />
|
|
282
|
+
), { size: 'sm' })}
|
|
283
|
+
>
|
|
284
|
+
Publish…
|
|
285
|
+
</button>
|
|
286
|
+
</OperationRow>
|
|
287
|
+
|
|
288
|
+
<OperationRow
|
|
289
|
+
label="Test suite" icon="bi-robot"
|
|
290
|
+
description="Publishes a whole sample suite — one job per channel plus dozens of immediate and delayed jobs."
|
|
291
|
+
note="Load-generating. It returns no ids; watch the dashboard."
|
|
292
|
+
>
|
|
293
|
+
<ArmedButton
|
|
294
|
+
className="btn-compact" label="Run suite" armedLabel="Click again to flood the queues"
|
|
295
|
+
disabled={!canManage}
|
|
296
|
+
onConfirm={async () => {
|
|
297
|
+
try { after(await publishTestSuite()); } catch (error) { failed(error, 'Test suite failed'); }
|
|
298
|
+
}}
|
|
299
|
+
/>
|
|
300
|
+
</OperationRow>
|
|
301
|
+
|
|
302
|
+
<OperationRow
|
|
303
|
+
label="Clear stuck" icon="bi-wrench"
|
|
304
|
+
description="Requeue in-flight jobs that have been claimed for longer than the idle threshold."
|
|
305
|
+
note={target ? <>Targets <code>{target}</code>.</> : <>No all-channel form exists server-side — this runs once per channel and reports each result.</>}
|
|
306
|
+
>
|
|
307
|
+
<ArmedButton
|
|
308
|
+
className="btn-compact" label="Clear stuck" armedLabel={`Click again to requeue in ${target || `${channels.length} channels`}`}
|
|
309
|
+
disabled={disabled}
|
|
310
|
+
onConfirm={() => runFanOut('cleared', (name) => clearStuck(name))}
|
|
311
|
+
/>
|
|
312
|
+
</OperationRow>
|
|
313
|
+
|
|
314
|
+
<OperationRow
|
|
315
|
+
label="Reclaim all" icon="bi-arrow-counterclockwise"
|
|
316
|
+
description="Requeue EVERY in-flight job, not only the idle ones (idle threshold 0)."
|
|
317
|
+
note={target ? <>Targets <code>{target}</code>.</> : <>Runs once per channel.</>}
|
|
318
|
+
>
|
|
319
|
+
<ArmedButton
|
|
320
|
+
className="btn-compact" label="Reclaim" armedLabel={`Click again to reclaim in ${target || `${channels.length} channels`}`}
|
|
321
|
+
disabled={disabled}
|
|
322
|
+
onConfirm={() => runFanOut('reclaimed', (name) => manualReclaim(name))}
|
|
323
|
+
/>
|
|
324
|
+
</OperationRow>
|
|
325
|
+
|
|
326
|
+
<OperationRow
|
|
327
|
+
label="Reset failed" icon="bi-arrow-repeat"
|
|
328
|
+
description="Move failed jobs back to pending and requeue them, up to 100 at a time."
|
|
329
|
+
note={target ? <>Targets <code>{target}</code>.</> : <>The backend handles the all-channel form itself.</>}
|
|
330
|
+
>
|
|
331
|
+
<ArmedButton
|
|
332
|
+
className="btn-compact" label="Reset failed" armedLabel="Click again to requeue failed jobs"
|
|
333
|
+
disabled={!canManage}
|
|
334
|
+
onConfirm={async () => {
|
|
335
|
+
try {
|
|
336
|
+
const result = await resetFailed({ channel: target || null });
|
|
337
|
+
after(`${result.reset_count} failed job(s) reset to pending`);
|
|
338
|
+
} catch (error) { failed(error, 'Reset failed'); }
|
|
339
|
+
}}
|
|
340
|
+
/>
|
|
341
|
+
</OperationRow>
|
|
342
|
+
|
|
343
|
+
<OperationRow
|
|
344
|
+
label="Clear queue" icon="bi-eraser"
|
|
345
|
+
description="Deletes the channel's Redis keys and cancels every DB-pending job on it."
|
|
346
|
+
note={target
|
|
347
|
+
? <>Targets <code>{target}</code>. Queued work is <strong>lost</strong>.</>
|
|
348
|
+
: <>Pick a single channel — clearing every queue at once is not offered.</>}
|
|
349
|
+
>
|
|
350
|
+
<ArmedButton
|
|
351
|
+
className="btn-compact btn-danger-ghost" label="Clear queue"
|
|
352
|
+
armedLabel={`Click again to wipe ${target}`}
|
|
353
|
+
disabled={disabled || !target}
|
|
354
|
+
onConfirm={async () => {
|
|
355
|
+
try {
|
|
356
|
+
const result = await clearQueue(target);
|
|
357
|
+
after(`Queue ${target} cleared · ${result.db_pending_canceled} pending job(s) canceled`);
|
|
358
|
+
} catch (error) { failed(error, 'Clear queue failed'); }
|
|
359
|
+
}}
|
|
360
|
+
/>
|
|
361
|
+
</OperationRow>
|
|
362
|
+
|
|
363
|
+
<OperationRow
|
|
364
|
+
label="Purge history" icon="bi-trash"
|
|
365
|
+
description="Delete job rows older than a cutoff, with their events and logs."
|
|
366
|
+
note="Runs a dry-run preview before anything is deleted."
|
|
367
|
+
>
|
|
368
|
+
<button
|
|
369
|
+
type="button" className="btn btn-compact btn-danger-ghost" disabled={!canManage}
|
|
370
|
+
onClick={() => void modal.open((close) => (
|
|
371
|
+
<PurgeDialog onClose={() => { close(null); onChanged?.(); }} />
|
|
372
|
+
), { size: 'sm' })}
|
|
373
|
+
>
|
|
374
|
+
Purge…
|
|
375
|
+
</button>
|
|
376
|
+
</OperationRow>
|
|
377
|
+
|
|
378
|
+
<OperationRow
|
|
379
|
+
label="Cleanup consumers" icon="bi-people"
|
|
380
|
+
description="Remove stale Redis stream consumers and, when empty, their groups."
|
|
381
|
+
note={target ? <>Targets <code>{target}</code>.</> : <>Cleans every channel.</>}
|
|
382
|
+
>
|
|
383
|
+
<ArmedButton
|
|
384
|
+
className="btn-compact" label="Cleanup" armedLabel="Click again to clean up consumers"
|
|
385
|
+
disabled={!canManage}
|
|
386
|
+
onConfirm={async () => {
|
|
387
|
+
try {
|
|
388
|
+
const result = await cleanupConsumers({ channel: target || null });
|
|
389
|
+
void modal.open((close) => (
|
|
390
|
+
<div className="modal-pad">
|
|
391
|
+
<h2 className="modal-title">Consumer cleanup</h2>
|
|
392
|
+
<JsonBlock value={result} label="Result" defaultOpen />
|
|
393
|
+
<div className="modal-actions">
|
|
394
|
+
<button type="button" className="btn btn-primary" onClick={() => close(null)}>Close</button>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
), { size: 'md' });
|
|
398
|
+
onChanged?.();
|
|
399
|
+
} catch (error) { failed(error, 'Cleanup failed'); }
|
|
400
|
+
}}
|
|
401
|
+
/>
|
|
402
|
+
</OperationRow>
|
|
403
|
+
|
|
404
|
+
<OperationRow
|
|
405
|
+
label="Rebuild scheduled" icon="bi-calendar-check"
|
|
406
|
+
description="Rebuild the Redis scheduled sets from the database's pending future jobs."
|
|
407
|
+
note={target ? <>Targets <code>{target}</code>.</> : <>Rebuilds every channel.</>}
|
|
408
|
+
>
|
|
409
|
+
<button
|
|
410
|
+
type="button" className="btn btn-compact" disabled={!canManage}
|
|
411
|
+
onClick={async () => {
|
|
412
|
+
try {
|
|
413
|
+
await rebuildScheduled({ channel: target || null });
|
|
414
|
+
after('Scheduled sets rebuilt from the database');
|
|
415
|
+
} catch (error) { failed(error, 'Rebuild failed'); }
|
|
416
|
+
}}
|
|
417
|
+
>
|
|
418
|
+
Rebuild
|
|
419
|
+
</button>
|
|
420
|
+
</OperationRow>
|
|
421
|
+
</div>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// JobRunnersStrip — the fleet summary that opens the dashboard. Port of
|
|
2
|
+
// web-mojo JobsRunnersStrip.js.
|
|
3
|
+
//
|
|
4
|
+
// Runners are the PRIMARY alert signal: if none are alive nothing moves, no
|
|
5
|
+
// matter how healthy the channel numbers look. Hence the position at the top
|
|
6
|
+
// and the critical empty state.
|
|
7
|
+
import { Badge, fmt } from '../../../ui';
|
|
8
|
+
import { showRunnerDetail } from '../RunnerDetail';
|
|
9
|
+
import {
|
|
10
|
+
formatHeartbeatAge,
|
|
11
|
+
heartbeatAgeSeconds,
|
|
12
|
+
runnerHealth,
|
|
13
|
+
type RunnerRow,
|
|
14
|
+
} from '../models';
|
|
15
|
+
import { useRunners } from '../queries';
|
|
16
|
+
|
|
17
|
+
/** Worst state in the fleet drives the header tone. */
|
|
18
|
+
function summaryTone(runners: RunnerRow[], loaded: boolean): 'success' | 'warning' | 'danger' | 'muted' {
|
|
19
|
+
if (!loaded) return 'muted';
|
|
20
|
+
if (runners.length === 0) return 'danger';
|
|
21
|
+
if (runners.some((runner) => runnerHealth(runner).key === 'down')) return 'danger';
|
|
22
|
+
if (runners.some((runner) => runnerHealth(runner).key === 'stale')) return 'warning';
|
|
23
|
+
return 'success';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function JobRunnersStrip() {
|
|
27
|
+
const query = useRunners();
|
|
28
|
+
const runners = query.data ?? [];
|
|
29
|
+
const loaded = !query.isPending && !query.isError;
|
|
30
|
+
const alive = runners.filter((runner) => runner.alive).length;
|
|
31
|
+
const processed = runners.reduce((sum, runner) => sum + (runner.jobs_processed || 0), 0);
|
|
32
|
+
const tone = summaryTone(runners, loaded);
|
|
33
|
+
|
|
34
|
+
const summary = query.isPending
|
|
35
|
+
? 'Loading runners…'
|
|
36
|
+
: query.isError
|
|
37
|
+
? 'Could not load runners'
|
|
38
|
+
: runners.length === 0
|
|
39
|
+
? 'No runners registered'
|
|
40
|
+
: `${alive} of ${runners.length} runner${runners.length === 1 ? '' : 's'} alive · ${fmt.number(processed)} jobs processed`;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<details className="panel jobs-strip" open>
|
|
44
|
+
<summary className="jobs-strip-head">
|
|
45
|
+
<span className="jobs-strip-title"><i className="bi bi-cpu" /> Runners</span>
|
|
46
|
+
<span className={`jobs-strip-summary jobs-text-${tone}`}>{summary}</span>
|
|
47
|
+
<i className="bi bi-chevron-down jobs-strip-chevron" />
|
|
48
|
+
</summary>
|
|
49
|
+
<ul className="jobs-strip-list">
|
|
50
|
+
{loaded && runners.length === 0 && (
|
|
51
|
+
<li className="jobs-strip-row jobs-strip-empty">
|
|
52
|
+
<i className="bi bi-exclamation-triangle" />
|
|
53
|
+
No runners registered — jobs will not be processed.
|
|
54
|
+
</li>
|
|
55
|
+
)}
|
|
56
|
+
{runners.map((runner) => {
|
|
57
|
+
const health = runnerHealth(runner);
|
|
58
|
+
return (
|
|
59
|
+
<li key={runner.id}>
|
|
60
|
+
<button type="button" className="jobs-strip-row" onClick={() => showRunnerDetail(runner)}>
|
|
61
|
+
<span className={`jobs-dot jobs-dot-${health.tone}`} />
|
|
62
|
+
<span className="jobs-strip-id">
|
|
63
|
+
<code>{runner.runner_id}</code>
|
|
64
|
+
<span className="dim">
|
|
65
|
+
{runner.channels.length > 0 ? runner.channels.join(', ') : 'no channels'}
|
|
66
|
+
{' · '}
|
|
67
|
+
{fmt.number(runner.jobs_processed)} done
|
|
68
|
+
{runner.jobs_failed > 0 && ` · ${fmt.number(runner.jobs_failed)} failed`}
|
|
69
|
+
</span>
|
|
70
|
+
</span>
|
|
71
|
+
<span className="dim jobs-strip-when" title={fmt.datetime(runner.last_heartbeat)}>
|
|
72
|
+
{formatHeartbeatAge(heartbeatAgeSeconds(runner.last_heartbeat))}
|
|
73
|
+
</span>
|
|
74
|
+
<Badge tone={health.tone}>{health.label}</Badge>
|
|
75
|
+
</button>
|
|
76
|
+
</li>
|
|
77
|
+
);
|
|
78
|
+
})}
|
|
79
|
+
</ul>
|
|
80
|
+
</details>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// JobThroughputSection — the metrics half of the dashboard. Port of web-mojo
|
|
2
|
+
// sections/JobOverviewSection.js (the two sparkline widgets) plus the full
|
|
3
|
+
// MetricsChart the source never had.
|
|
4
|
+
//
|
|
5
|
+
// The per-channel switch is the interesting part. The backend's slug naming is
|
|
6
|
+
// ASYMMETRIC: publishing is recorded as `jobs.published.<channel>` while
|
|
7
|
+
// terminal outcomes are recorded as `jobs.channel.<channel>.completed` and
|
|
8
|
+
// `.failed`. There is NO per-channel slug for retried or expired, so those two
|
|
9
|
+
// series exist only in the all-channel view and the UI says so rather than
|
|
10
|
+
// plotting an empty line.
|
|
11
|
+
import { useRef, useState } from 'react';
|
|
12
|
+
import { MetricsChart, MetricsMiniWidget } from '../../../charts';
|
|
13
|
+
|
|
14
|
+
const GLOBAL_SLUGS = ['jobs.completed', 'jobs.failed', 'jobs.retried', 'jobs.expired'];
|
|
15
|
+
const GLOBAL_LABELS: Record<string, string> = {
|
|
16
|
+
'jobs.completed': 'Completed',
|
|
17
|
+
'jobs.failed': 'Failed',
|
|
18
|
+
'jobs.retried': 'Retried',
|
|
19
|
+
'jobs.expired': 'Expired',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The three slugs a channel actually has, in the backend's own naming. */
|
|
23
|
+
function channelSlugs(channel: string): { slugs: string[]; labels: Record<string, string> } {
|
|
24
|
+
const published = `jobs.published.${channel}`;
|
|
25
|
+
const completed = `jobs.channel.${channel}.completed`;
|
|
26
|
+
const failed = `jobs.channel.${channel}.failed`;
|
|
27
|
+
return {
|
|
28
|
+
slugs: [published, completed, failed],
|
|
29
|
+
labels: { [published]: 'Published', [completed]: 'Completed', [failed]: 'Failed' },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function JobThroughputSection({ channels }: { channels: string[] }) {
|
|
34
|
+
const [channel, setChannel] = useState('');
|
|
35
|
+
// A channel that disappears from the list (stream removed, settings
|
|
36
|
+
// change) must not leave the chart requesting slugs that no longer exist.
|
|
37
|
+
// The fallback is loud, but once per channel — not once per render.
|
|
38
|
+
const warned = useRef<Set<string>>(new Set());
|
|
39
|
+
const active = channel && channels.includes(channel) ? channel : '';
|
|
40
|
+
if (channel && !active && !warned.current.has(channel)) {
|
|
41
|
+
warned.current.add(channel);
|
|
42
|
+
console.warn(`[admin/jobs] channel ${JSON.stringify(channel)} is no longer reported — falling back to all channels.`);
|
|
43
|
+
}
|
|
44
|
+
const scoped = active ? channelSlugs(active) : null;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="jobs-throughput">
|
|
48
|
+
<div className="jobs-throughput-minis">
|
|
49
|
+
<MetricsMiniWidget
|
|
50
|
+
title="Jobs published"
|
|
51
|
+
icon="bi bi-upload"
|
|
52
|
+
slugs={['jobs.published']}
|
|
53
|
+
account="global"
|
|
54
|
+
granularity="days"
|
|
55
|
+
defaultRange="30d"
|
|
56
|
+
chartType="line"
|
|
57
|
+
height={90}
|
|
58
|
+
showTrending
|
|
59
|
+
showSettings
|
|
60
|
+
settingsKey="admin-jobs-published"
|
|
61
|
+
/>
|
|
62
|
+
<MetricsMiniWidget
|
|
63
|
+
title="Jobs failed"
|
|
64
|
+
icon="bi bi-exclamation-octagon"
|
|
65
|
+
slugs={['jobs.failed']}
|
|
66
|
+
account="global"
|
|
67
|
+
granularity="days"
|
|
68
|
+
defaultRange="30d"
|
|
69
|
+
chartType="line"
|
|
70
|
+
tone="bad"
|
|
71
|
+
height={90}
|
|
72
|
+
showTrending
|
|
73
|
+
showSettings
|
|
74
|
+
settingsKey="admin-jobs-failed"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div className="panel jobs-throughput-chart">
|
|
79
|
+
<div className="toolbar">
|
|
80
|
+
<div className="toolbar-heading">
|
|
81
|
+
<div className="eyebrow">Throughput</div>
|
|
82
|
+
<h2 className="panel-title">Job outcomes</h2>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="toolbar-controls">
|
|
85
|
+
<label className="jobs-inline-field">
|
|
86
|
+
<span className="dim">Channel</span>
|
|
87
|
+
<select
|
|
88
|
+
className="input input-compact"
|
|
89
|
+
value={active}
|
|
90
|
+
onChange={(event) => setChannel(event.target.value)}
|
|
91
|
+
aria-label="Metric channel"
|
|
92
|
+
>
|
|
93
|
+
<option value="">All channels</option>
|
|
94
|
+
{channels.map((name) => <option key={name} value={name}>{name}</option>)}
|
|
95
|
+
</select>
|
|
96
|
+
</label>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<MetricsChart
|
|
100
|
+
key={active || 'all'}
|
|
101
|
+
title={active ? `Outcomes · ${active}` : 'Outcomes · all channels'}
|
|
102
|
+
slugs={scoped ? scoped.slugs : GLOBAL_SLUGS}
|
|
103
|
+
seriesLabels={scoped ? scoped.labels : GLOBAL_LABELS}
|
|
104
|
+
account="global"
|
|
105
|
+
defaultRange="7d"
|
|
106
|
+
defaultGranularity="days"
|
|
107
|
+
defaultType="bar"
|
|
108
|
+
height={260}
|
|
109
|
+
/>
|
|
110
|
+
{active && (
|
|
111
|
+
<p className="dim jobs-throughput-note">
|
|
112
|
+
Retried and expired are only recorded system-wide — the backend records no per-channel slug for them.
|
|
113
|
+
</p>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { QueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { FormWizard, modal, type FormData } from '../../ui';
|
|
4
|
+
import { useCan } from '../../client';
|
|
5
|
+
import { getDnsAdminIntegration } from '../dns/dns-integration';
|
|
6
|
+
import { DNS_VIEW_PERMISSIONS } from '../dns/models';
|
|
7
|
+
import { onboardEmailDomain } from './api';
|
|
8
|
+
import type { DomainActionResult, EmailDomainRow } from './models';
|
|
9
|
+
|
|
10
|
+
interface WizardOutcome{result:DomainActionResult;recordsHref:string|null}
|
|
11
|
+
function mountedHref(relative:string):string{const clean=relative.replace(/^\/+|\/+$/g,'');const embedded=window.location.hash.startsWith('#/system/');return `#/${embedded?'system/':''}${clean}`;}
|
|
12
|
+
function OnboardResult({outcome,onClose,onNavigate}:{outcome:WizardOutcome;onClose:()=>void;onNavigate:()=>void}){const {result}=outcome;return <div className="modal-pad"><h2 className="modal-title">Onboarding result</h2><p><b>{result.domain}</b> · {result.region}</p>{result.provider&&<p>Provider: {result.provider}{result.applied!=null?` · ${result.applied} records applied`:''}</p>}{result.receipt_rule&&<p><b>Receipt rule:</b> <code>{result.receipt_rule}</code></p>}{result.rule_set&&<p><b>Rule set:</b> <code>{result.rule_set}</code></p>}{result.dns_records.length>0?<div className="table-wrap"><table className="tbl"><thead><tr><th>Type</th><th>Name</th><th>Value</th><th>TTL</th></tr></thead><tbody>{result.dns_records.map((record,index)=><tr key={`${record.type}:${record.name}:${index}`}><td>{record.type}</td><td><code>{record.name}</code></td><td><code>{record.value}</code></td><td>{record.ttl}</td></tr>)}</tbody></table></div>:<p className="dim">No DNS records were returned.</p>}{outcome.recordsHref&&<p><button className="btn" onClick={onNavigate}>Open DNS records</button></p>}{Object.entries(result.topic_arns??{}).map(([key,value])=><p key={key}><b>{key} topic:</b> <code>{value}</code></p>)}{result.notes.map(note=><p key={note}>{note}</p>)}<div className="modal-actions"><button className="btn" onClick={onClose}>Close</button></div></div>}
|
|
13
|
+
|
|
14
|
+
function WizardBody({domain,queryClient,onPending,onSuccess,onClose}:{domain:EmailDomainRow;queryClient:QueryClient;onPending:(value:boolean)=>void;onSuccess:(value:WizardOutcome)=>void;onClose:()=>void}){
|
|
15
|
+
const integration=getDnsAdminIntegration();const dnsPermission=useCan(DNS_VIEW_PERMISSIONS);const canViewDns=dnsPermission.can;const canResolve=canViewDns&&Boolean(integration?.resolveDomainByName&&integration.recordsHref);const normalizedDomain=domain.name.trim().toLowerCase().replace(/\.$/,'');const [dnsDomainId,setDnsDomainId]=useState<number|null>(null);const [resolvedFor,setResolvedFor]=useState<string|null>(null);
|
|
16
|
+
useEffect(()=>{let alive=true;if(dnsPermission.isLoading){setDnsDomainId(null);setResolvedFor(null);return;}if(!canResolve||!integration?.resolveDomainByName){setDnsDomainId(null);setResolvedFor(null);return;}setDnsDomainId(null);setResolvedFor(null);void integration.resolveDomainByName(normalizedDomain).then(id=>{if(alive)setDnsDomainId(id);}).catch(()=>{if(alive)setDnsDomainId(null);}).finally(()=>{if(alive)setResolvedFor(normalizedDomain);});return()=>{alive=false;};},[canResolve,dnsPermission.isLoading,integration,normalizedDomain]);
|
|
17
|
+
const managedReady=canViewDns&&dnsDomainId!=null&&Boolean(integration?.recordsHref);
|
|
18
|
+
const finish=async(data:FormData)=>{const useDns=data.use_dnsman==='managed';if(useDns&&!managedReady)throw new Error('Managed DNS is unavailable for this exact domain. Choose manual DNS.');const receiving=Boolean(data.receiving_enabled);const bucket=String(data.s3_inbound_bucket??'').trim();if(receiving&&!bucket)throw new Error('Inbound S3 bucket is required when receiving is enabled.');const mailFrom=String(data.mail_from_subdomain??'').trim();if(Boolean(data.ensure_mail_from)&&!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(mailFrom))throw new Error('MAIL FROM subdomain is invalid.');const endpoints:Record<string,string>={};for(const key of ['bounce','complaint','delivery','inbound'] as const){const value=String(data[`${key}_endpoint`]??'').trim();if(value){let url:URL;try{url=new URL(value);}catch{throw new Error(`${key} endpoint must be an HTTPS URL.`);}if(url.protocol!=='https:'||url.username||url.password)throw new Error(`${key} endpoint must be an HTTPS URL without credentials.`);endpoints[key]=value;}}
|
|
19
|
+
const input={use_dnsman:useDns,region:String(data.region||domain.region),receiving_enabled:receiving,...(bucket?{s3_inbound_bucket:bucket}:{}),s3_inbound_prefix:String(data.s3_inbound_prefix??''),ensure_mail_from:Boolean(data.ensure_mail_from),...(mailFrom?{mail_from_subdomain:mailFrom}:{}),...(Object.keys(endpoints).length?{endpoints}:{})};const ok=await modal.confirm({title:'Apply SES onboarding?',message:<><p>This performs external SES/SNS and receiving configuration. Managed mode may also write DNS.</p><p><b>{domain.name}</b> · {useDns?'Managed DNS':'Manual DNS'} · {input.region}</p><p>Receiving: {receiving?`enabled → ${bucket}`:'disabled'} · MAIL FROM: {input.ensure_mail_from?mailFrom:'off'}</p></>,confirmText:'Apply onboarding'});if(!ok)return;onPending(true);try{const result=await onboardEmailDomain(queryClient,domain.id,input);const recordsHref=managedReady&&integration?.recordsHref&&dnsDomainId!=null?mountedHref(integration.recordsHref(dnsDomainId)):null;onSuccess({result,recordsHref});}finally{onPending(false);}};
|
|
20
|
+
if(dnsPermission.isLoading||(canResolve&&resolvedFor!==normalizedDomain))return <div className="modal-pad"><h2 className="modal-title">Onboard {domain.name}</h2><p className="dim">Resolving DNS permissions and the exact managed domain…</p><div className="modal-actions"><button className="btn" onClick={onClose}>Cancel</button></div></div>;
|
|
21
|
+
return <div className="modal-pad"><h2 className="modal-title">Onboard {domain.name}</h2>{!managedReady&&<div className="form-alert">Managed DNS is unavailable for this exact domain or permission set. Manual mode returns complete records.</div>}<FormWizard mode="wizard" finishText="Review and onboard" busyText="Onboarding…" onCancel={onClose} onFinish={finish} initial={{region:domain.region,receiving_enabled:domain.receiving_enabled,s3_inbound_bucket:domain.s3_inbound_bucket??'',s3_inbound_prefix:domain.s3_inbound_prefix??'',use_dnsman:managedReady?'managed':'manual',ensure_mail_from:true,mail_from_subdomain:'feedback'}} sections={[{key:'domain',label:'Domain',description:'Confirm region and receiving intent.',fields:[{name:'region',label:'AWS region',type:'text',required:true},{name:'receiving_enabled',label:'Enable receiving',type:'switch'}]},{key:'receiving',label:'Receiving',fields:[{name:'s3_inbound_bucket',label:'Inbound S3 bucket',type:'text',help:'Required when receiving is enabled.'},{name:'s3_inbound_prefix',label:'Object prefix',type:'text'}]},{key:'notifications',label:'Notifications',optional:true,fields:[{name:'bounce_endpoint',label:'Bounce HTTPS endpoint',type:'text'},{name:'complaint_endpoint',label:'Complaint HTTPS endpoint',type:'text'},{name:'delivery_endpoint',label:'Delivery HTTPS endpoint',type:'text'},{name:'inbound_endpoint',label:'Inbound HTTPS endpoint',type:'text'}]},{key:'dns',label:'DNS',description:managedReady?'Managed DNS uses the exact registered DNSMan domain and its stored credential.':'Manual DNS keeps provider changes in your hands.',fields:[{name:'use_dnsman',label:'DNS method',type:'select',options:managedReady?[{value:'manual',label:'Manual DNS'},{value:'managed',label:'Managed DNS'}]:[{value:'manual',label:'Manual DNS'}]},{name:'ensure_mail_from',label:'Configure MAIL FROM',type:'switch'},{name:'mail_from_subdomain',label:'MAIL FROM subdomain',type:'text'}]},{key:'review',label:'Review',description:'Finish opens an exact confirmation before one external request.',fields:[]}]} /></div>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function openEmailDomainWizard(domain:EmailDomainRow,queryClient:QueryClient):Promise<void>{let pending=false;let outcome:WizardOutcome|null=null;await modal.open(close=><WizardBody domain={domain} queryClient={queryClient} onPending={value=>{pending=value;}} onSuccess={value=>{outcome=value;close(null);}} onClose={()=>close(null)}/>,{size:'lg',canDismiss:()=>!pending});const completed=outcome as WizardOutcome|null;if(completed){const navigate=await modal.open<'dns'|null>(close=><OnboardResult outcome={completed} onClose={()=>close(null)} onNavigate={()=>close('dns')}/>,{size:'lg'});if(navigate==='dns'&&completed.recordsHref)window.location.hash=completed.recordsHref.replace(/^#/,'');}}
|