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,721 @@
|
|
|
1
|
+
// JobDetail — the job inspector, opened as a `modal.detail` from the jobs
|
|
2
|
+
// table (and from the runner inspector's job lists). Port of web-mojo
|
|
3
|
+
// JobDetailsView.js (912 lines) onto portal-mojo's DetailView + the detail
|
|
4
|
+
// primitives.
|
|
5
|
+
//
|
|
6
|
+
// Carried from the source: the StatusPanel-led Overview with its per-state
|
|
7
|
+
// narrative, the four KPIs (attempt / runtime / retries left / next), the
|
|
8
|
+
// Execution flat rows with an error block, the Lifecycle timeline, and the
|
|
9
|
+
// Payload · Events · Logs · Retry history · Similar sections behind the
|
|
10
|
+
// Activity/Related dividers, plus the header chips and the state-conditional
|
|
11
|
+
// kebab.
|
|
12
|
+
//
|
|
13
|
+
// Re-wired, because the backend does not serve what the source assumed:
|
|
14
|
+
// · `recent_events` exists in NO graph — only inside the `get_status` action
|
|
15
|
+
// payload. The lifecycle timeline is `/api/jobs/event?job=&graph=timeline`
|
|
16
|
+
// (useJobTimeline), and those entries carry no `id` by design.
|
|
17
|
+
// · logs filter by the RELATION name (`job=`). The source sent `job_id=`,
|
|
18
|
+
// which django-mojo accepts as an FK attname and then dies on — HTTP 500,
|
|
19
|
+
// not an unfiltered list.
|
|
20
|
+
// · `retry_request` ALWAYS republishes a NEW job. The source toasted "Retry
|
|
21
|
+
// scheduled" and left the operator looking at a row that did not resume;
|
|
22
|
+
// here the new id is named and offered.
|
|
23
|
+
// · `cancel_request` must be sent as `true` — `{}` is falsy in Python.
|
|
24
|
+
// · there is no `stack_trace` and no `is_retriable` in any graph.
|
|
25
|
+
// Retriability is the SERVICE rule (canRetryJob), computed client-side.
|
|
26
|
+
// · every trusted-HTML slot (`meta`, `detail`, `auxFn`) is a ReactNode.
|
|
27
|
+
import { useState, type ReactNode } from 'react';
|
|
28
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
29
|
+
import { useCan } from '../../client';
|
|
30
|
+
import {
|
|
31
|
+
ArmedButton,
|
|
32
|
+
Badge,
|
|
33
|
+
DetailView,
|
|
34
|
+
Eyebrow,
|
|
35
|
+
FlatRow,
|
|
36
|
+
JsonBlock,
|
|
37
|
+
StatusPanel,
|
|
38
|
+
Timeline,
|
|
39
|
+
fmt,
|
|
40
|
+
modal,
|
|
41
|
+
toast,
|
|
42
|
+
type DetailMenuEntry,
|
|
43
|
+
type TimelineItem,
|
|
44
|
+
type Tone,
|
|
45
|
+
} from '../../ui';
|
|
46
|
+
import { JobIdentity, JobStatusBadge, relativeWhen } from './columns';
|
|
47
|
+
import {
|
|
48
|
+
canCancelJob,
|
|
49
|
+
canRetryJob,
|
|
50
|
+
isScheduledJob,
|
|
51
|
+
isOverdueJob,
|
|
52
|
+
jobStatusIcon,
|
|
53
|
+
jobStatusTone,
|
|
54
|
+
JOBS_MANAGE_PERMS,
|
|
55
|
+
JOBS_VIEW_PERMS,
|
|
56
|
+
JobEventModel,
|
|
57
|
+
JobLogModel,
|
|
58
|
+
JobModel,
|
|
59
|
+
type JobEventKind,
|
|
60
|
+
type JobRow,
|
|
61
|
+
type JobTimelineEntry,
|
|
62
|
+
} from './models';
|
|
63
|
+
import { jobsKeys, useJobLogs, useJobTimeline } from './queries';
|
|
64
|
+
|
|
65
|
+
// ── Event vocabulary ──────────────────────────────────────────────────
|
|
66
|
+
// JobEvent.event is a fixed choice list on the backend; an unknown value still
|
|
67
|
+
// falls back WITH a warn rather than rendering nothing.
|
|
68
|
+
|
|
69
|
+
const EVENT_TONE: Record<JobEventKind, Tone> = {
|
|
70
|
+
created: 'muted',
|
|
71
|
+
queued: 'info',
|
|
72
|
+
scheduled: 'warning',
|
|
73
|
+
running: 'primary',
|
|
74
|
+
claimed: 'primary',
|
|
75
|
+
released: 'warning',
|
|
76
|
+
retry: 'warning',
|
|
77
|
+
canceled: 'muted',
|
|
78
|
+
completed: 'success',
|
|
79
|
+
failed: 'danger',
|
|
80
|
+
expired: 'warning',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const EVENT_ICON: Record<JobEventKind, string> = {
|
|
84
|
+
created: 'bi-plus-circle',
|
|
85
|
+
queued: 'bi-inbox',
|
|
86
|
+
scheduled: 'bi-clock',
|
|
87
|
+
running: 'bi-play-fill',
|
|
88
|
+
claimed: 'bi-hand-index',
|
|
89
|
+
released: 'bi-hand-index-thumb',
|
|
90
|
+
retry: 'bi-arrow-repeat',
|
|
91
|
+
canceled: 'bi-x-circle',
|
|
92
|
+
completed: 'bi-check-circle',
|
|
93
|
+
failed: 'bi-x-octagon',
|
|
94
|
+
expired: 'bi-clock-history',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function eventTone(event: string): Tone {
|
|
98
|
+
const tone = EVENT_TONE[event as JobEventKind];
|
|
99
|
+
if (tone) return tone;
|
|
100
|
+
console.warn(`[admin/jobs] unknown job event ${JSON.stringify(event)} — falling back to "muted".`);
|
|
101
|
+
return 'muted';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function eventIcon(event: string): string {
|
|
105
|
+
return EVENT_ICON[event as JobEventKind] ?? 'bi-dot';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One event row (timeline graph or default graph) → a Timeline item. */
|
|
109
|
+
function toTimelineItem(entry: Pick<JobTimelineEntry, 'event' | 'at' | 'runner_id' | 'details'>): TimelineItem {
|
|
110
|
+
const detailKeys = Object.keys(entry.details ?? {});
|
|
111
|
+
return {
|
|
112
|
+
tone: eventTone(entry.event),
|
|
113
|
+
title: entry.event,
|
|
114
|
+
meta: fmt.relative(entry.at),
|
|
115
|
+
body: (
|
|
116
|
+
<>
|
|
117
|
+
{entry.runner_id && <>runner <code>{entry.runner_id}</code>{detailKeys.length > 0 && ' · '}</>}
|
|
118
|
+
{detailKeys.length > 0 && <code className="jobs-json-inline">{JSON.stringify(entry.details)}</code>}
|
|
119
|
+
</>
|
|
120
|
+
),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Small presentational pieces ───────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function Kpi({ label, value, tone }: { label: string; value: ReactNode; tone?: Tone | null }) {
|
|
127
|
+
return (
|
|
128
|
+
<div className={`jobs-kpi${tone ? ` jobs-kpi-${tone}` : ''}`}>
|
|
129
|
+
<div className="jobs-kpi-label">{label}</div>
|
|
130
|
+
<div className="jobs-kpi-value">{value}</div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function TablePane({ pending, error, empty, children }: {
|
|
136
|
+
pending: boolean;
|
|
137
|
+
error: unknown;
|
|
138
|
+
empty: boolean;
|
|
139
|
+
children: ReactNode;
|
|
140
|
+
}) {
|
|
141
|
+
if (pending) return <p className="dim">Loading…</p>;
|
|
142
|
+
if (error) return <p className="jobs-text-danger">{error instanceof Error ? error.message : 'Request failed'}</p>;
|
|
143
|
+
if (empty) return <p className="dim-italic">Nothing to show.</p>;
|
|
144
|
+
return <div className="jobs-pane-scroll">{children}</div>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `last_error` is an unbounded exception string — a 200KB traceback must not
|
|
149
|
+
* be pasted into the DOM just because it exists. The first 2000 characters
|
|
150
|
+
* render immediately and the rest is one click away. There is no stack trace
|
|
151
|
+
* to offer beside it: no graph carries `stack_trace`, and implying otherwise
|
|
152
|
+
* would send the operator looking for something that is not on the wire.
|
|
153
|
+
*/
|
|
154
|
+
const ERROR_PREVIEW_CHARS = 2000;
|
|
155
|
+
|
|
156
|
+
function ErrorBlock({ text }: { text: string }) {
|
|
157
|
+
const [full, setFull] = useState(false);
|
|
158
|
+
const long = text.length > ERROR_PREVIEW_CHARS;
|
|
159
|
+
return (
|
|
160
|
+
<>
|
|
161
|
+
<pre className="jobs-error-block">{full || !long ? text : `${text.slice(0, ERROR_PREVIEW_CHARS)}…`}</pre>
|
|
162
|
+
{long && (
|
|
163
|
+
<button type="button" className="btn btn-compact jobs-error-more" onClick={() => setFull((v) => !v)}>
|
|
164
|
+
{full ? 'Show less' : `Show the full error (${fmt.number(text.length)} characters)`}
|
|
165
|
+
</button>
|
|
166
|
+
)}
|
|
167
|
+
</>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Cancel + retry dialogs ────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Cancel is irreversible and its effect depends on whether the runner is still
|
|
175
|
+
* alive, so it is ARMED rather than a plain confirm — and the dialog says which
|
|
176
|
+
* of the two outcomes to expect before the second click.
|
|
177
|
+
*/
|
|
178
|
+
function CancelJobDialog({ job, onConfirm, onClose }: {
|
|
179
|
+
job: JobRow;
|
|
180
|
+
onConfirm: () => Promise<void>;
|
|
181
|
+
onClose: () => void;
|
|
182
|
+
}) {
|
|
183
|
+
return (
|
|
184
|
+
<div className="modal-pad">
|
|
185
|
+
<h2 className="modal-title">Cancel job</h2>
|
|
186
|
+
<p className="dim">
|
|
187
|
+
<code>{fmt.truncateMiddle(job.id, 16)}</code> · <code>{job.func || 'unknown function'}</code>
|
|
188
|
+
</p>
|
|
189
|
+
<p className="jobs-note jobs-note-warn">
|
|
190
|
+
<i className="bi bi-exclamation-triangle" />
|
|
191
|
+
{job.status === 'running'
|
|
192
|
+
? <>A running job is canceled <strong>cooperatively</strong>: the flag is set and the runner stops at its next check. If its runner is not alive the backend force-cancels instead and the job ends immediately.</>
|
|
193
|
+
: <>The job is canceled outright and will never run.</>}
|
|
194
|
+
</p>
|
|
195
|
+
<div className="modal-actions">
|
|
196
|
+
<button type="button" className="btn" onClick={onClose}>Keep job</button>
|
|
197
|
+
<ArmedButton
|
|
198
|
+
label="Cancel job"
|
|
199
|
+
armedLabel="Click again to cancel this job"
|
|
200
|
+
icon="bi-x-circle"
|
|
201
|
+
className="btn-danger-ghost"
|
|
202
|
+
onConfirm={onConfirm}
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Retry ALWAYS republishes a new job (`{original_job_id, new_job_id, delayed}`)
|
|
211
|
+
* — the row on screen does not resume. The dialog says so before the run and
|
|
212
|
+
* hands back the new id afterwards, because "Retry scheduled" (the source's
|
|
213
|
+
* toast) left an operator watching a job that was never going to move.
|
|
214
|
+
*/
|
|
215
|
+
function RetryJobDialog({ job, onRun, onOpenNewJob, onClose }: {
|
|
216
|
+
job: JobRow;
|
|
217
|
+
onRun: (delay: number | null) => Promise<{ newJobId: string; delayed: boolean }>;
|
|
218
|
+
onOpenNewJob: (newJobId: string) => void;
|
|
219
|
+
onClose: () => void;
|
|
220
|
+
}) {
|
|
221
|
+
const [delay, setDelay] = useState(0);
|
|
222
|
+
const [busy, setBusy] = useState(false);
|
|
223
|
+
const [result, setResult] = useState<{ newJobId: string; delayed: boolean } | null>(null);
|
|
224
|
+
|
|
225
|
+
const run = async () => {
|
|
226
|
+
setBusy(true);
|
|
227
|
+
try {
|
|
228
|
+
setResult(await onRun(delay > 0 ? delay : null));
|
|
229
|
+
} catch (error) {
|
|
230
|
+
toast.error(error instanceof Error ? error.message : 'Retry failed');
|
|
231
|
+
} finally {
|
|
232
|
+
setBusy(false);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
if (result) {
|
|
237
|
+
return (
|
|
238
|
+
<div className="modal-pad">
|
|
239
|
+
<h2 className="modal-title">Retry published</h2>
|
|
240
|
+
<div className="jobs-result-note">
|
|
241
|
+
<p className="jobs-note jobs-note-info">
|
|
242
|
+
<i className="bi bi-info-circle" />
|
|
243
|
+
A <strong>new</strong> job was published{result.delayed ? ` and runs in ${delay}s` : ''}. The original
|
|
244
|
+
job keeps its own history — it did not resume.
|
|
245
|
+
</p>
|
|
246
|
+
<FlatRow label="Original"><code>{fmt.truncateMiddle(job.id, 16)}</code></FlatRow>
|
|
247
|
+
<FlatRow label="New job"><code>{fmt.truncateMiddle(result.newJobId, 16)}</code></FlatRow>
|
|
248
|
+
</div>
|
|
249
|
+
<div className="modal-actions">
|
|
250
|
+
<button type="button" className="btn" onClick={onClose}>Close</button>
|
|
251
|
+
<button type="button" className="btn btn-primary" onClick={() => onOpenNewJob(result.newJobId)}>
|
|
252
|
+
<i className="bi bi-box-arrow-up-right" /> Open new job
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div className="modal-pad">
|
|
261
|
+
<h2 className="modal-title">Retry job</h2>
|
|
262
|
+
<p className="dim">
|
|
263
|
+
<code>{fmt.truncateMiddle(job.id, 16)}</code> · <code>{job.func || 'unknown function'}</code>
|
|
264
|
+
</p>
|
|
265
|
+
<p className="jobs-note jobs-note-info">
|
|
266
|
+
<i className="bi bi-info-circle" /> Retrying republishes the payload as a <strong>new job</strong>. This
|
|
267
|
+
one stays {job.status} until it is purged.
|
|
268
|
+
</p>
|
|
269
|
+
<label className="jobs-field">
|
|
270
|
+
<span>Delay before it runs (seconds, 0 = immediately)</span>
|
|
271
|
+
<input
|
|
272
|
+
type="number" className="input input-compact" min={0} step={5}
|
|
273
|
+
value={delay}
|
|
274
|
+
onChange={(event) => setDelay(Math.max(0, Number(event.target.value) || 0))}
|
|
275
|
+
/>
|
|
276
|
+
</label>
|
|
277
|
+
<div className="modal-actions">
|
|
278
|
+
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
|
279
|
+
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void run()}>
|
|
280
|
+
{busy ? 'Publishing…' : 'Retry'}
|
|
281
|
+
</button>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Narrative ─────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
interface JobNarrative {
|
|
290
|
+
tone: Tone;
|
|
291
|
+
state: string;
|
|
292
|
+
headline: string;
|
|
293
|
+
meta: ReactNode;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The StatusPanel copy per lifecycle state — web-mojo's `_narrative` port. */
|
|
297
|
+
function jobNarrative(job: JobRow): JobNarrative {
|
|
298
|
+
const duration = job.duration_ms > 0 ? fmt.duration(job.duration_ms) : null;
|
|
299
|
+
const attempts = <>Attempt <strong>{job.attempt}</strong> of <strong>{job.max_retries || '∞'}</strong></>;
|
|
300
|
+
|
|
301
|
+
if (isScheduledJob(job)) {
|
|
302
|
+
const overdue = isOverdueJob(job);
|
|
303
|
+
return {
|
|
304
|
+
tone: overdue ? 'warning' : 'info',
|
|
305
|
+
state: overdue ? 'OVERDUE' : 'SCHEDULED',
|
|
306
|
+
headline: overdue
|
|
307
|
+
? `Was due ${relativeWhen(job.run_at)} and has not run`
|
|
308
|
+
: `Runs ${relativeWhen(job.run_at)}`,
|
|
309
|
+
meta: <><code>{job.func || 'unknown'}</code> on channel <code>{job.channel}</code> · queued {fmt.relative(job.created)}</>,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (job.status === 'running') {
|
|
313
|
+
return {
|
|
314
|
+
tone: 'primary',
|
|
315
|
+
state: job.cancel_requested ? 'CANCELING' : 'RUNNING',
|
|
316
|
+
headline: job.runner_id
|
|
317
|
+
? `Running on ${job.runner_id} · started ${fmt.relative(job.started_at)}`
|
|
318
|
+
: `Running · started ${fmt.relative(job.started_at)}`,
|
|
319
|
+
meta: job.cancel_requested
|
|
320
|
+
? <>{attempts} · a cancel has been requested; the runner stops at its next check</>
|
|
321
|
+
: attempts,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
if (job.status === 'completed') {
|
|
325
|
+
return {
|
|
326
|
+
tone: 'success',
|
|
327
|
+
state: 'COMPLETED',
|
|
328
|
+
headline: duration ? `Completed in ${duration}` : 'Completed',
|
|
329
|
+
meta: <>Finished {fmt.relative(job.finished_at)}{job.runner_id ? <> on <code>{job.runner_id}</code></> : null}</>,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (job.status === 'failed') {
|
|
333
|
+
const firstLine = (job.last_error || '').split('\n')[0] || 'Failed';
|
|
334
|
+
return {
|
|
335
|
+
tone: 'danger',
|
|
336
|
+
state: 'FAILED',
|
|
337
|
+
headline: duration ? `Failed after ${duration}` : 'Failed',
|
|
338
|
+
meta: <>{attempts}{canRetryJob(job) ? ' · retry available' : ''}<br /><code className="jobs-text-danger">{firstLine}</code></>,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
if (job.status === 'canceled') {
|
|
342
|
+
return {
|
|
343
|
+
tone: 'muted',
|
|
344
|
+
state: 'CANCELED',
|
|
345
|
+
headline: 'Canceled',
|
|
346
|
+
meta: <>Canceled {fmt.relative(job.finished_at ?? job.modified)} · retry is still allowed</>,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
if (job.status === 'expired') {
|
|
350
|
+
return {
|
|
351
|
+
tone: 'warning',
|
|
352
|
+
state: 'EXPIRED',
|
|
353
|
+
headline: 'Expired before it ran',
|
|
354
|
+
meta: <>Created {fmt.relative(job.created)}{job.expires_at ? <> · expiry {fmt.datetime(job.expires_at)}</> : null}</>,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
tone: jobStatusTone(job.status),
|
|
359
|
+
state: 'PENDING',
|
|
360
|
+
headline: 'Waiting for a runner',
|
|
361
|
+
meta: <>Queued on channel <code>{job.channel}</code> · {fmt.relative(job.created)}</>,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── The inspector ─────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
const EVENT_PAGE = 50;
|
|
368
|
+
const SIMILAR_PAGE = 15;
|
|
369
|
+
|
|
370
|
+
export function JobDetail({ jobId, onClose }: { jobId: string; onClose: () => void }) {
|
|
371
|
+
const queryClient = useQueryClient();
|
|
372
|
+
const { can: canView } = useCan(JOBS_VIEW_PERMS);
|
|
373
|
+
const { can: canManage } = useCan(JOBS_MANAGE_PERMS);
|
|
374
|
+
|
|
375
|
+
// Every query is view-gated: a caller without the grant issues no request
|
|
376
|
+
// at all rather than firing one and catching a 403.
|
|
377
|
+
const query = JobModel.useOne(canView ? jobId : null);
|
|
378
|
+
const job = query.data ?? null;
|
|
379
|
+
const timeline = useJobTimeline(canView ? jobId : null);
|
|
380
|
+
const logs = useJobLogs(canView ? jobId : null);
|
|
381
|
+
const events = JobEventModel.useList(
|
|
382
|
+
{ job: jobId, sort: '-at', size: EVENT_PAGE },
|
|
383
|
+
{ enabled: canView },
|
|
384
|
+
);
|
|
385
|
+
const retries = JobEventModel.useList(
|
|
386
|
+
{ job: jobId, event: 'retry', sort: '-at', size: EVENT_PAGE },
|
|
387
|
+
{ enabled: canView },
|
|
388
|
+
);
|
|
389
|
+
const similar = JobModel.useList(
|
|
390
|
+
{ func: job?.func ?? '', sort: '-created', size: SIMILAR_PAGE },
|
|
391
|
+
{ enabled: canView && !!job?.func },
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const cancelAction = JobModel.useAction('cancel_request');
|
|
395
|
+
const retryAction = JobModel.useAction('retry_request');
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* One refresh for the whole record: the job, its events (timeline + the
|
|
399
|
+
* events/retry lists share the endpoint root), its logs, and the jobs
|
|
400
|
+
* domain root so the dashboard totals follow a cancel or a retry.
|
|
401
|
+
*/
|
|
402
|
+
const refresh = () => {
|
|
403
|
+
void queryClient.invalidateQueries({ queryKey: JobModel.keys.root });
|
|
404
|
+
void queryClient.invalidateQueries({ queryKey: JobEventModel.keys.root });
|
|
405
|
+
void queryClient.invalidateQueries({ queryKey: JobLogModel.keys.root });
|
|
406
|
+
void queryClient.invalidateQueries({ queryKey: jobsKeys.root });
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
if (query.isPending) return <div className="modal-pad dim">Loading job…</div>;
|
|
410
|
+
if (!job || query.error) {
|
|
411
|
+
return <div className="modal-pad text-bad">{query.error?.message ?? 'Job not found'}</div>;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const narrative = jobNarrative(job);
|
|
415
|
+
const shortId = fmt.truncateMiddle(job.id, 16);
|
|
416
|
+
const retriesLeft = Math.max(0, (job.max_retries ?? 0) - (job.attempt ?? 0));
|
|
417
|
+
const similarRows = (similar.data?.rows ?? []).filter((row) => row.id !== job.id);
|
|
418
|
+
|
|
419
|
+
const nextLabel = isScheduledJob(job) ? relativeWhen(job.run_at)
|
|
420
|
+
: job.status === 'running' ? 'In flight'
|
|
421
|
+
: canRetryJob(job) ? 'Retry available'
|
|
422
|
+
: '—';
|
|
423
|
+
const nextTone: Tone | null = isScheduledJob(job) ? (isOverdueJob(job) ? 'warning' : 'info')
|
|
424
|
+
: job.status === 'running' ? 'primary'
|
|
425
|
+
: canRetryJob(job) ? 'info'
|
|
426
|
+
: null;
|
|
427
|
+
|
|
428
|
+
const runCancel = async () => {
|
|
429
|
+
try {
|
|
430
|
+
const outcome = await cancelAction.mutateAsync({ id: job.id, payload: true });
|
|
431
|
+
const body = outcome.body as { message?: string; forced?: boolean };
|
|
432
|
+
// A running job on a DEAD runner is force-canceled server-side and
|
|
433
|
+
// is already over — saying "cancellation requested" would be a lie.
|
|
434
|
+
toast.success(body.forced
|
|
435
|
+
? `Job ${shortId} canceled — its runner was not alive, so the cancel was forced.`
|
|
436
|
+
: body.message ?? 'Cancellation requested');
|
|
437
|
+
refresh();
|
|
438
|
+
} catch (error) {
|
|
439
|
+
toast.error(error instanceof Error ? error.message : 'Cancel failed');
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const openCancel = () => {
|
|
444
|
+
void modal.open((close) => (
|
|
445
|
+
<CancelJobDialog
|
|
446
|
+
job={job}
|
|
447
|
+
onClose={() => close(null)}
|
|
448
|
+
// The dialog stays up while the request is in flight and closes
|
|
449
|
+
// on its outcome — a cancel that 400s must not vanish silently.
|
|
450
|
+
onConfirm={async () => { await runCancel(); close(null); }}
|
|
451
|
+
/>
|
|
452
|
+
), { size: 'sm' });
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const openRetry = () => {
|
|
456
|
+
void modal.open((close) => (
|
|
457
|
+
<RetryJobDialog
|
|
458
|
+
job={job}
|
|
459
|
+
onClose={() => close(null)}
|
|
460
|
+
onRun={async (delay) => {
|
|
461
|
+
const outcome = await retryAction.mutateAsync({
|
|
462
|
+
id: job.id,
|
|
463
|
+
payload: delay == null ? { retry: true } : { retry: true, delay },
|
|
464
|
+
});
|
|
465
|
+
const body = outcome.body as { new_job_id?: string; delayed?: boolean };
|
|
466
|
+
const newJobId = body.new_job_id ?? '';
|
|
467
|
+
toast.success(`Retry published as job ${fmt.truncateMiddle(newJobId, 16)}`);
|
|
468
|
+
refresh();
|
|
469
|
+
return { newJobId, delayed: Boolean(body.delayed) };
|
|
470
|
+
}}
|
|
471
|
+
onOpenNewJob={(newJobId) => {
|
|
472
|
+
// Follow the retry: close both this dialog and the original
|
|
473
|
+
// job's inspector, then open the job that actually runs.
|
|
474
|
+
close(null);
|
|
475
|
+
onClose();
|
|
476
|
+
showJobDetail(newJobId);
|
|
477
|
+
}}
|
|
478
|
+
/>
|
|
479
|
+
), { size: 'sm' });
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const contextMenu: DetailMenuEntry<JobRow>[] = [
|
|
483
|
+
{ label: 'Refresh', icon: 'bi-arrow-clockwise', onSelect: refresh },
|
|
484
|
+
{ divider: true },
|
|
485
|
+
{
|
|
486
|
+
label: 'Retry job…', icon: 'bi-arrow-repeat',
|
|
487
|
+
permissions: JOBS_MANAGE_PERMS,
|
|
488
|
+
when: (row) => (row ? canRetryJob(row) : false),
|
|
489
|
+
onSelect: openRetry,
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
label: 'Cancel job…', icon: 'bi-x-circle', danger: true,
|
|
493
|
+
permissions: JOBS_MANAGE_PERMS,
|
|
494
|
+
when: (row) => (row ? canCancelJob(row) : false),
|
|
495
|
+
onSelect: openCancel,
|
|
496
|
+
},
|
|
497
|
+
];
|
|
498
|
+
|
|
499
|
+
return (
|
|
500
|
+
<DetailView<JobRow>
|
|
501
|
+
icon={isScheduledJob(job) ? 'bi-clock-fill' : jobStatusIcon(job.status)}
|
|
502
|
+
title={job.func || 'unknown.task'}
|
|
503
|
+
subtitle={narrative.headline}
|
|
504
|
+
chips={[
|
|
505
|
+
{ icon: 'bi-broadcast', text: job.channel, tone: 'info' },
|
|
506
|
+
{ text: `#${job.id.slice(-8)}`, tone: 'muted' },
|
|
507
|
+
...(job.runner_id ? [{ icon: 'bi-cpu', text: job.runner_id, tone: 'muted' as const }] : []),
|
|
508
|
+
...(job.attempt > 0 || job.max_retries > 0
|
|
509
|
+
? [{ text: `attempt ${job.attempt}/${job.max_retries || '∞'}`, tone: 'muted' as const }]
|
|
510
|
+
: []),
|
|
511
|
+
...(job.duration_ms > 0 ? [{ text: fmt.duration(job.duration_ms), tone: 'muted' as const }] : []),
|
|
512
|
+
...(job.cancel_requested
|
|
513
|
+
? [{ icon: 'bi-exclamation-triangle', text: 'cancel requested', tone: 'warning' as const }]
|
|
514
|
+
: []),
|
|
515
|
+
]}
|
|
516
|
+
menuContext={job}
|
|
517
|
+
contextMenu={contextMenu}
|
|
518
|
+
// Keyed by section key — and `similar` only exists when `func` does,
|
|
519
|
+
// so the key is omitted with the section rather than left dangling
|
|
520
|
+
// (DetailView warns about a badge key that names no section).
|
|
521
|
+
badges={{
|
|
522
|
+
events: events.data?.count || null,
|
|
523
|
+
logs: logs.data?.count || null,
|
|
524
|
+
retries: retries.data?.count || null,
|
|
525
|
+
...(job.func ? { similar: similarRows.length || null } : {}),
|
|
526
|
+
}}
|
|
527
|
+
sections={[
|
|
528
|
+
{
|
|
529
|
+
key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
|
|
530
|
+
<>
|
|
531
|
+
<StatusPanel
|
|
532
|
+
tone={narrative.tone}
|
|
533
|
+
state={narrative.state}
|
|
534
|
+
headline={narrative.headline}
|
|
535
|
+
meta={narrative.meta}
|
|
536
|
+
actions={canManage ? (
|
|
537
|
+
<div className="jobs-detail-actions">
|
|
538
|
+
{canRetryJob(job) && (
|
|
539
|
+
<button type="button" className="btn btn-compact btn-primary" onClick={openRetry}>
|
|
540
|
+
<i className="bi bi-arrow-repeat" /> Retry…
|
|
541
|
+
</button>
|
|
542
|
+
)}
|
|
543
|
+
{canCancelJob(job) && (
|
|
544
|
+
<button type="button" className="btn btn-compact btn-danger-ghost" onClick={openCancel}>
|
|
545
|
+
<i className="bi bi-x-circle" /> Cancel…
|
|
546
|
+
</button>
|
|
547
|
+
)}
|
|
548
|
+
</div>
|
|
549
|
+
) : null}
|
|
550
|
+
/>
|
|
551
|
+
|
|
552
|
+
<div className="jobs-kpi-grid">
|
|
553
|
+
<Kpi
|
|
554
|
+
label="Attempt"
|
|
555
|
+
value={`${job.attempt} / ${job.max_retries || '∞'}`}
|
|
556
|
+
tone={job.attempt > 0 && job.status === 'failed' ? 'warning' : null}
|
|
557
|
+
/>
|
|
558
|
+
<Kpi label="Runtime" value={job.duration_ms > 0 ? fmt.duration(job.duration_ms) : '—'} />
|
|
559
|
+
<Kpi label="Retries left" value={retriesLeft} tone={retriesLeft === 0 && job.status === 'failed' ? 'danger' : null} />
|
|
560
|
+
<Kpi label={isScheduledJob(job) ? 'Scheduled' : 'Next'} value={nextLabel} tone={nextTone} />
|
|
561
|
+
</div>
|
|
562
|
+
|
|
563
|
+
<Eyebrow>Execution</Eyebrow>
|
|
564
|
+
<FlatRow label="Function"><code>{job.func || '—'}</code></FlatRow>
|
|
565
|
+
<FlatRow label="Channel"><code>{job.channel}</code></FlatRow>
|
|
566
|
+
<FlatRow label="Status"><JobStatusBadge status={job.status} /></FlatRow>
|
|
567
|
+
<FlatRow label="Runner">
|
|
568
|
+
{job.runner_id ? <code>{job.runner_id}</code> : <span className="dim">not claimed</span>}
|
|
569
|
+
</FlatRow>
|
|
570
|
+
<FlatRow label="Created">{fmt.datetime(job.created)}</FlatRow>
|
|
571
|
+
<FlatRow label="Started">
|
|
572
|
+
{job.started_at == null ? <span className="dim">—</span> : fmt.datetime(job.started_at)}
|
|
573
|
+
</FlatRow>
|
|
574
|
+
<FlatRow label="Finished">
|
|
575
|
+
{job.finished_at == null ? <span className="dim">—</span> : fmt.datetime(job.finished_at)}
|
|
576
|
+
</FlatRow>
|
|
577
|
+
{job.run_at != null && (
|
|
578
|
+
<FlatRow label="Runs at">{fmt.datetime(job.run_at)} · {relativeWhen(job.run_at)}</FlatRow>
|
|
579
|
+
)}
|
|
580
|
+
{job.expires_at != null && (
|
|
581
|
+
<FlatRow label="Expires">{fmt.datetime(job.expires_at)}</FlatRow>
|
|
582
|
+
)}
|
|
583
|
+
{job.broadcast && <FlatRow label="Broadcast"><Badge tone="info">every runner</Badge></FlatRow>}
|
|
584
|
+
{job.max_exec_seconds != null && (
|
|
585
|
+
<FlatRow label="Max execution">{fmt.duration(job.max_exec_seconds, 's')}</FlatRow>
|
|
586
|
+
)}
|
|
587
|
+
{job.cancel_requested && (
|
|
588
|
+
<FlatRow label="Cancel"><Badge tone="warning">requested</Badge></FlatRow>
|
|
589
|
+
)}
|
|
590
|
+
{job.last_error && <ErrorBlock text={job.last_error} />}
|
|
591
|
+
|
|
592
|
+
<Eyebrow>Lifecycle</Eyebrow>
|
|
593
|
+
{timeline.isPending
|
|
594
|
+
? <p className="dim">Loading lifecycle…</p>
|
|
595
|
+
: timeline.error
|
|
596
|
+
? <p className="jobs-text-danger">{timeline.error instanceof Error ? timeline.error.message : 'Could not load the lifecycle.'}</p>
|
|
597
|
+
: (
|
|
598
|
+
<Timeline
|
|
599
|
+
items={(timeline.data?.rows ?? []).map(toTimelineItem)}
|
|
600
|
+
emptyText="No events recorded yet. Lifecycle entries appear as the runner claims the job and emits events."
|
|
601
|
+
/>
|
|
602
|
+
)}
|
|
603
|
+
</>
|
|
604
|
+
),
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
key: 'payload', label: 'Payload', icon: 'bi-braces', render: () => (
|
|
608
|
+
<>
|
|
609
|
+
<JsonBlock value={job.payload} label="Payload" defaultOpen />
|
|
610
|
+
{Object.keys(job.metadata ?? {}).length > 0 && (
|
|
611
|
+
<JsonBlock value={job.metadata} label="Metadata" />
|
|
612
|
+
)}
|
|
613
|
+
</>
|
|
614
|
+
),
|
|
615
|
+
},
|
|
616
|
+
{ divider: 'Activity' },
|
|
617
|
+
{
|
|
618
|
+
key: 'events', label: 'Events', icon: 'bi-list-ul', render: () => (
|
|
619
|
+
<TablePane pending={events.isPending} error={events.error} empty={(events.data?.rows.length ?? 0) === 0}>
|
|
620
|
+
<table className="jobs-table">
|
|
621
|
+
<thead><tr><th>When</th><th>Event</th><th>Runner</th><th>Attempt</th><th>Details</th></tr></thead>
|
|
622
|
+
<tbody>
|
|
623
|
+
{(events.data?.rows ?? []).map((event) => (
|
|
624
|
+
<tr key={event.id}>
|
|
625
|
+
<td title={fmt.datetime(event.at)}>{fmt.relative(event.at)}</td>
|
|
626
|
+
<td>
|
|
627
|
+
<Badge tone={eventTone(event.event)}>
|
|
628
|
+
<i className={`bi ${eventIcon(event.event)}`} /> {event.event}
|
|
629
|
+
</Badge>
|
|
630
|
+
</td>
|
|
631
|
+
<td>{event.runner_id ? <code className="jobs-id">{fmt.truncateMiddle(event.runner_id, 14)}</code> : <span className="dim">—</span>}</td>
|
|
632
|
+
<td>{event.attempt}</td>
|
|
633
|
+
<td>
|
|
634
|
+
{Object.keys(event.details ?? {}).length > 0
|
|
635
|
+
? <code className="jobs-json-inline">{JSON.stringify(event.details)}</code>
|
|
636
|
+
: <span className="dim">—</span>}
|
|
637
|
+
</td>
|
|
638
|
+
</tr>
|
|
639
|
+
))}
|
|
640
|
+
</tbody>
|
|
641
|
+
</table>
|
|
642
|
+
</TablePane>
|
|
643
|
+
),
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
key: 'logs', label: 'Logs', icon: 'bi-code-square', render: () => (
|
|
647
|
+
<TablePane pending={logs.isPending} error={logs.error} empty={(logs.data?.rows.length ?? 0) === 0}>
|
|
648
|
+
<table className="jobs-table">
|
|
649
|
+
<thead><tr><th>When</th><th>Kind</th><th>Message</th></tr></thead>
|
|
650
|
+
<tbody>
|
|
651
|
+
{(logs.data?.rows ?? []).map((log) => (
|
|
652
|
+
<tr key={log.id}>
|
|
653
|
+
<td title={fmt.datetime(log.created)}>{fmt.relative(log.created)}</td>
|
|
654
|
+
<td>
|
|
655
|
+
<Badge tone={log.kind === 'error' ? 'danger' : log.kind === 'warn' ? 'warning' : 'muted'}>
|
|
656
|
+
{log.kind}
|
|
657
|
+
</Badge>
|
|
658
|
+
</td>
|
|
659
|
+
<td>{log.message}</td>
|
|
660
|
+
</tr>
|
|
661
|
+
))}
|
|
662
|
+
</tbody>
|
|
663
|
+
</table>
|
|
664
|
+
</TablePane>
|
|
665
|
+
),
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
key: 'retries', label: 'Retry history', icon: 'bi-arrow-repeat', render: () => (
|
|
669
|
+
<>
|
|
670
|
+
<p className="jobs-note jobs-note-info">
|
|
671
|
+
<i className="bi bi-info-circle" /> Each retry republished the payload as a NEW job; the
|
|
672
|
+
<code>new_job_id</code> in an entry’s details is the job that actually ran.
|
|
673
|
+
</p>
|
|
674
|
+
{retries.isPending
|
|
675
|
+
? <p className="dim">Loading retries…</p>
|
|
676
|
+
: (
|
|
677
|
+
<Timeline
|
|
678
|
+
items={(retries.data?.rows ?? []).map(toTimelineItem)}
|
|
679
|
+
emptyText="This job has never been retried."
|
|
680
|
+
/>
|
|
681
|
+
)}
|
|
682
|
+
</>
|
|
683
|
+
),
|
|
684
|
+
},
|
|
685
|
+
...(job.func
|
|
686
|
+
? [{
|
|
687
|
+
divider: 'Related' as const,
|
|
688
|
+
}, {
|
|
689
|
+
key: 'similar', label: 'Similar', icon: 'bi-files', render: () => (
|
|
690
|
+
<>
|
|
691
|
+
<Eyebrow>Recent jobs running <code>{job.func}</code></Eyebrow>
|
|
692
|
+
<TablePane pending={similar.isPending} error={similar.error} empty={similarRows.length === 0}>
|
|
693
|
+
<table className="jobs-table">
|
|
694
|
+
<thead><tr><th>Job</th><th>Status</th><th>Created</th><th>Duration</th></tr></thead>
|
|
695
|
+
<tbody>
|
|
696
|
+
{similarRows.map((row) => (
|
|
697
|
+
<tr key={row.id} className="row-click" onClick={() => showJobDetail(row.id)}>
|
|
698
|
+
<td><JobIdentity job={row} /></td>
|
|
699
|
+
<td><JobStatusBadge status={row.status} /></td>
|
|
700
|
+
<td title={fmt.datetime(row.created)}>{fmt.relative(row.created)}</td>
|
|
701
|
+
<td>{row.duration_ms > 0 ? fmt.duration(row.duration_ms) : '—'}</td>
|
|
702
|
+
</tr>
|
|
703
|
+
))}
|
|
704
|
+
</tbody>
|
|
705
|
+
</table>
|
|
706
|
+
</TablePane>
|
|
707
|
+
</>
|
|
708
|
+
),
|
|
709
|
+
}]
|
|
710
|
+
: []),
|
|
711
|
+
]}
|
|
712
|
+
initialSection="overview"
|
|
713
|
+
onClose={onClose}
|
|
714
|
+
/>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Open the inspector as a KISS detail modal — no record-detail route. */
|
|
719
|
+
export function showJobDetail(jobId: string): void {
|
|
720
|
+
void modal.detail((close) => <JobDetail jobId={jobId} onClose={() => close(null)} />);
|
|
721
|
+
}
|