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,562 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { mojoSave, useCan } from '../../client';
|
|
4
|
+
import type { RecordFeedItem } from '../../client/record-feed';
|
|
5
|
+
import {
|
|
6
|
+
Badge, CollectionSelect, DetailView, MarkdownView, ModelTable, RecordFeed, SchemaForm, fmt, modal, toast,
|
|
7
|
+
type Column, type Field, type FilterDef, type FormData, type Tone,
|
|
8
|
+
} from '../../ui';
|
|
9
|
+
import { AssistantContextLauncher } from '../assistant/launchers';
|
|
10
|
+
import { createTicketNoteAdapter } from '../../client/record-feed';
|
|
11
|
+
import {
|
|
12
|
+
MaestroItemLinkModel, TICKET_MANAGE_PERMS, TICKET_USER_LOOKUP_PERMS,
|
|
13
|
+
TicketModel, buildTicketActionResponseBody, invalidateTicketDependents,
|
|
14
|
+
isTicketActionDisabled, isTicketTerminal, knownOptionsWithCurrent, relationId, relationLabel,
|
|
15
|
+
type TicketNoteAction, type TicketRow,
|
|
16
|
+
} from './models';
|
|
17
|
+
|
|
18
|
+
export const TICKET_STATUSES = [
|
|
19
|
+
'new', 'open', 'in_progress', 'pending', 'paused', 'llm_review',
|
|
20
|
+
'assistant_review', 'resolved', 'qa', 'closed', 'ignored',
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export const TICKET_CATEGORIES = [
|
|
24
|
+
'ticket', 'bug', 'feature', 'incident', 'security', 'fulfillment',
|
|
25
|
+
'new_user', 'new_group', 'qa',
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
function label(value: string): string {
|
|
29
|
+
return value.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function statusTone(status: string): Tone {
|
|
33
|
+
if (status === 'resolved' || status === 'closed') return 'success';
|
|
34
|
+
if (status === 'ignored' || status === 'paused') return 'muted';
|
|
35
|
+
if (status === 'new' || status === 'pending' || status.endsWith('_review')) return 'warning';
|
|
36
|
+
return 'info';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function priorityTone(priority: number): Tone {
|
|
40
|
+
if (priority >= 8) return 'danger';
|
|
41
|
+
if (priority >= 5) return 'warning';
|
|
42
|
+
return 'muted';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function selectOptions(values: readonly string[]) {
|
|
46
|
+
return values.map((value) => ({ value, label: label(value) }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ticketChanges(data: FormData): Record<string, unknown> {
|
|
50
|
+
const title = String(data.title ?? '').trim();
|
|
51
|
+
if (!title) throw new Error('Title is required');
|
|
52
|
+
const priority = Number(String(data.priority ?? '').trim());
|
|
53
|
+
if (!Number.isInteger(priority) || priority < 1 || priority > 10) {
|
|
54
|
+
throw new Error('Priority must be a whole number from 1 to 10');
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
title,
|
|
58
|
+
description: String(data.description ?? ''),
|
|
59
|
+
status: String(data.status ?? 'new'),
|
|
60
|
+
category: String(data.category ?? 'ticket'),
|
|
61
|
+
priority,
|
|
62
|
+
assignee: data.assignee === '' || data.assignee == null ? null : data.assignee,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function TicketEditor({ row, close }: { row?: TicketRow; close(saved: boolean): void }) {
|
|
67
|
+
const save = TicketModel.useSave();
|
|
68
|
+
const { can: canLookupUsers } = useCan(TICKET_USER_LOOKUP_PERMS);
|
|
69
|
+
const { can: canManage } = useCan(TICKET_MANAGE_PERMS);
|
|
70
|
+
const canAssign = canManage && canLookupUsers;
|
|
71
|
+
const queryClient = useQueryClient();
|
|
72
|
+
const [priority, setPriority] = useState(String(row?.priority ?? 5));
|
|
73
|
+
const [priorityError, setPriorityError] = useState('');
|
|
74
|
+
const fields = useMemo<Field[]>(() => [
|
|
75
|
+
{ name: 'title', type: 'text', label: 'Title', required: true },
|
|
76
|
+
{ name: 'description', type: 'textarea', label: 'Description' },
|
|
77
|
+
{
|
|
78
|
+
name: 'status', type: 'select', label: 'Status', required: true, columns: 6,
|
|
79
|
+
options: selectOptions(knownOptionsWithCurrent(TICKET_STATUSES, row?.status)),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'category', type: 'select', label: 'Category', required: true, columns: 6,
|
|
83
|
+
options: selectOptions(knownOptionsWithCurrent(TICKET_CATEGORIES, row?.category)),
|
|
84
|
+
},
|
|
85
|
+
...(canAssign ? [{
|
|
86
|
+
name: 'assignee', type: 'collection', label: 'Assignee', columns: 6,
|
|
87
|
+
endpoint: '/api/user', labelField: 'display_name', valueField: 'id',
|
|
88
|
+
placeholder: 'Search users…',
|
|
89
|
+
} satisfies Field] : []),
|
|
90
|
+
], [canAssign, row?.category, row?.status]);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="modal-pad ticket-editor">
|
|
94
|
+
<h2 className="modal-title">{row ? `Edit ticket #${row.id}` : 'Create ticket'}</h2>
|
|
95
|
+
<label className="field ticket-editor-priority">
|
|
96
|
+
<span className="field-label">Priority (1–10) <em>*</em></span>
|
|
97
|
+
<input
|
|
98
|
+
className={`input${priorityError ? ' input-invalid' : ''}`}
|
|
99
|
+
type="number"
|
|
100
|
+
min={1}
|
|
101
|
+
max={10}
|
|
102
|
+
step={1}
|
|
103
|
+
value={priority}
|
|
104
|
+
onChange={(event) => { setPriority(event.target.value); setPriorityError(''); }}
|
|
105
|
+
/>
|
|
106
|
+
{priorityError && <span className="field-error">{priorityError}</span>}
|
|
107
|
+
{!priorityError && <span className="field-help">10 is the most urgent.</span>}
|
|
108
|
+
</label>
|
|
109
|
+
<SchemaForm
|
|
110
|
+
fields={fields}
|
|
111
|
+
initial={{
|
|
112
|
+
title: row?.title ?? '', description: row?.description ?? '',
|
|
113
|
+
status: row?.status ?? 'new', category: row?.category ?? 'ticket',
|
|
114
|
+
...(canAssign ? { assignee: relationId(row?.assignee) } : {}),
|
|
115
|
+
}}
|
|
116
|
+
submitText={row ? 'Save changes' : 'Create ticket'}
|
|
117
|
+
onCancel={() => close(false)}
|
|
118
|
+
onSubmit={async (data) => {
|
|
119
|
+
const numericPriority = Number(priority);
|
|
120
|
+
if (!Number.isInteger(numericPriority) || numericPriority < 1 || numericPriority > 10) {
|
|
121
|
+
setPriorityError('Priority must be a whole number from 1 to 10');
|
|
122
|
+
throw new Error('Choose a valid priority');
|
|
123
|
+
}
|
|
124
|
+
const saved = await save.mutateAsync({
|
|
125
|
+
id: row?.id ?? null,
|
|
126
|
+
changes: ticketChanges({ ...data, priority: numericPriority }),
|
|
127
|
+
});
|
|
128
|
+
await invalidateTicketDependents(queryClient, saved.id);
|
|
129
|
+
toast.success(row ? 'Ticket updated' : 'Ticket created');
|
|
130
|
+
close(true);
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function openTicketEditor(row?: TicketRow): Promise<void> {
|
|
138
|
+
await modal.open<boolean>((close) => <TicketEditor row={row} close={close} />, { size: 'lg' });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function InlineTicketSelect({ row, field, values }: {
|
|
142
|
+
row: TicketRow;
|
|
143
|
+
field: 'status' | 'category';
|
|
144
|
+
values: readonly string[];
|
|
145
|
+
}) {
|
|
146
|
+
const save = TicketModel.useSave();
|
|
147
|
+
const queryClient = useQueryClient();
|
|
148
|
+
const [error, setError] = useState('');
|
|
149
|
+
const current = row[field];
|
|
150
|
+
const change = async (next: string) => {
|
|
151
|
+
if (next === current) return;
|
|
152
|
+
setError('');
|
|
153
|
+
try {
|
|
154
|
+
await save.mutateAsync({ id: row.id, changes: { [field]: next } });
|
|
155
|
+
await invalidateTicketDependents(queryClient, row.id);
|
|
156
|
+
} catch (cause) {
|
|
157
|
+
const message = cause instanceof Error ? cause.message : `Failed to update ${field}`;
|
|
158
|
+
setError(message);
|
|
159
|
+
toast.error(message);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
return (
|
|
163
|
+
<div className="ticket-inline-field" onClick={(event) => event.stopPropagation()}>
|
|
164
|
+
<select
|
|
165
|
+
className={`ticket-inline-select ticket-inline-select-${field}`}
|
|
166
|
+
aria-label={`${label(field)} for ticket ${row.id}`}
|
|
167
|
+
value={current}
|
|
168
|
+
disabled={save.isPending}
|
|
169
|
+
onChange={(event) => void change(event.target.value)}
|
|
170
|
+
>
|
|
171
|
+
{knownOptionsWithCurrent(values, current).map((value) => (
|
|
172
|
+
<option value={value} key={value}>{label(value)}</option>
|
|
173
|
+
))}
|
|
174
|
+
</select>
|
|
175
|
+
{error && <span className="ticket-inline-error" role="alert">{error}</span>}
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function objectValue(value: unknown): Record<string, unknown> {
|
|
181
|
+
return value != null && typeof value === 'object' && !Array.isArray(value)
|
|
182
|
+
? value as Record<string, unknown>
|
|
183
|
+
: {};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function actionFromItem(item: RecordFeedItem): TicketNoteAction | null {
|
|
187
|
+
const action = objectValue(item.metadata.action);
|
|
188
|
+
return Object.keys(action).length ? action : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function scalar(value: unknown): string | null {
|
|
192
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function referenceText(value: unknown): string | null {
|
|
197
|
+
const direct = scalar(value);
|
|
198
|
+
if (direct) return direct;
|
|
199
|
+
const reference = objectValue(value);
|
|
200
|
+
const explicit = scalar(reference.label);
|
|
201
|
+
if (explicit) return explicit;
|
|
202
|
+
const model = scalar(reference.model);
|
|
203
|
+
const pk = scalar(reference.pk) ?? scalar(reference.id);
|
|
204
|
+
return model && pk ? `${model.split('.').at(-1)} #${pk}` : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const KNOWN_ACTION_HANDLERS = new Set([
|
|
208
|
+
'incident.rule_approval', 'incident.block_confirm', 'incident.rule_update', 'incident.escalate',
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
function TicketActionCard({ item, ticket, canManage }: {
|
|
212
|
+
item: RecordFeedItem;
|
|
213
|
+
ticket: TicketRow;
|
|
214
|
+
canManage: boolean;
|
|
215
|
+
}) {
|
|
216
|
+
const action = actionFromItem(item);
|
|
217
|
+
const queryClient = useQueryClient();
|
|
218
|
+
const [pending, setPending] = useState(false);
|
|
219
|
+
const [error, setError] = useState('');
|
|
220
|
+
if (!action) return null;
|
|
221
|
+
|
|
222
|
+
const context = objectValue(action.context);
|
|
223
|
+
const handler = scalar(action.handler);
|
|
224
|
+
const cardLabel = handler && KNOWN_ACTION_HANDLERS.has(handler) ? (scalar(action.label) ?? 'Action required') : 'Action required';
|
|
225
|
+
const detail = scalar(context.detail) ?? scalar(context.description);
|
|
226
|
+
const target = referenceText(context.target) ?? referenceText(context.ref);
|
|
227
|
+
const references = Array.isArray(action.references)
|
|
228
|
+
? action.references.map(referenceText).filter((entry): entry is string => entry != null).slice(0, 5)
|
|
229
|
+
: [];
|
|
230
|
+
const disabled = isTicketActionDisabled(action, ticket.status, canManage, pending);
|
|
231
|
+
|
|
232
|
+
const respond = async (decision: 'approve' | 'deny') => {
|
|
233
|
+
const confirmed = await modal.confirm({
|
|
234
|
+
title: decision === 'approve' ? 'Approve action?' : 'Deny action?',
|
|
235
|
+
message: `${decision === 'approve' ? 'Approve' : 'Deny'} “${cardLabel}”? The response is recorded as a new ticket note.`,
|
|
236
|
+
confirmText: decision === 'approve' ? 'Approve' : 'Deny',
|
|
237
|
+
danger: decision === 'deny',
|
|
238
|
+
});
|
|
239
|
+
if (!confirmed) return;
|
|
240
|
+
setPending(true);
|
|
241
|
+
setError('');
|
|
242
|
+
try {
|
|
243
|
+
await mojoSave('/api/incident/ticket/note', null, buildTicketActionResponseBody(
|
|
244
|
+
ticket.id, relationId(ticket.group), action, decision,
|
|
245
|
+
));
|
|
246
|
+
await invalidateTicketDependents(queryClient, ticket.id);
|
|
247
|
+
toast.success(decision === 'approve' ? 'Action approved' : 'Action denied');
|
|
248
|
+
} catch (cause) {
|
|
249
|
+
const message = cause instanceof Error ? cause.message : 'Failed to record action response';
|
|
250
|
+
setError(message);
|
|
251
|
+
toast.error(message);
|
|
252
|
+
} finally {
|
|
253
|
+
setPending(false);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<section className={`ticket-action-card${action.resolved ? ' is-resolved' : ''}`} aria-label={cardLabel}>
|
|
259
|
+
<div className="ticket-action-card-head">
|
|
260
|
+
<strong>{cardLabel}</strong>
|
|
261
|
+
{Boolean(action.resolved) && <Badge tone="success">Resolved</Badge>}
|
|
262
|
+
</div>
|
|
263
|
+
{detail && <p>{detail}</p>}
|
|
264
|
+
{target && <p className="dim">Target: {target}</p>}
|
|
265
|
+
{references.length > 0 && <ul>{references.map((reference) => <li key={reference}>{reference}</li>)}</ul>}
|
|
266
|
+
{action.resolution != null && <p className="dim">Resolution: {scalar(action.resolution) ?? 'Recorded'}</p>}
|
|
267
|
+
{!action.resolved && (
|
|
268
|
+
<div className="ticket-action-card-actions">
|
|
269
|
+
<button type="button" className="btn btn-primary" disabled={disabled} onClick={() => void respond('approve')}>Approve</button>
|
|
270
|
+
<button type="button" className="btn btn-danger" disabled={disabled} onClick={() => void respond('deny')}>Deny</button>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
274
|
+
</section>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function safeRemoteUrl(value: unknown): string | null {
|
|
279
|
+
if (typeof value !== 'string') return null;
|
|
280
|
+
try {
|
|
281
|
+
const url = new URL(value);
|
|
282
|
+
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function TicketDetail({ ticketId, close }: { ticketId: number; close(): void }) {
|
|
289
|
+
const queryClient = useQueryClient();
|
|
290
|
+
const ticketQuery = TicketModel.useOne(ticketId);
|
|
291
|
+
const linkQuery = MaestroItemLinkModel.useList({ ticket: ticketId, size: 1, graph: 'default' });
|
|
292
|
+
const { can: canManage, me } = useCan(TICKET_MANAGE_PERMS);
|
|
293
|
+
const { can: canAssign } = useCan(TICKET_USER_LOOKUP_PERMS);
|
|
294
|
+
const save = TicketModel.useSave();
|
|
295
|
+
const enableLlm = TicketModel.useAction('enable_llm');
|
|
296
|
+
const disableLlm = TicketModel.useAction('disable_llm');
|
|
297
|
+
const pushToMaestro = TicketModel.useAction('push_to_maestro');
|
|
298
|
+
const [polling, setPolling] = useState(false);
|
|
299
|
+
const [error, setError] = useState('');
|
|
300
|
+
const [priorityDraft, setPriorityDraft] = useState('');
|
|
301
|
+
const [priorityError, setPriorityError] = useState('');
|
|
302
|
+
const [descriptionDraft, setDescriptionDraft] = useState('');
|
|
303
|
+
const pollDeadline = useRef(0);
|
|
304
|
+
const link = linkQuery.data?.rows[0];
|
|
305
|
+
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (!ticketQuery.data) return;
|
|
308
|
+
setPriorityDraft(String(ticketQuery.data.priority));
|
|
309
|
+
setPriorityError('');
|
|
310
|
+
setDescriptionDraft(ticketQuery.data.description ?? '');
|
|
311
|
+
}, [ticketQuery.data?.id, ticketQuery.data?.priority, ticketQuery.data?.description]);
|
|
312
|
+
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
if (!polling || link) {
|
|
315
|
+
if (link) setPolling(false);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const poll = () => {
|
|
319
|
+
if (Date.now() >= pollDeadline.current) {
|
|
320
|
+
setPolling(false);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
void linkQuery.refetch();
|
|
324
|
+
};
|
|
325
|
+
const timer = window.setInterval(poll, 2_000);
|
|
326
|
+
return () => window.clearInterval(timer);
|
|
327
|
+
}, [polling, link, linkQuery.refetch]);
|
|
328
|
+
|
|
329
|
+
if (ticketQuery.isLoading) return <div className="ticket-detail-state"><span className="spinner" /> Loading ticket… <button type="button" className="btn" onClick={close}>Close</button></div>;
|
|
330
|
+
if (ticketQuery.error || !ticketQuery.data) {
|
|
331
|
+
return (
|
|
332
|
+
<div className="ticket-detail-state">
|
|
333
|
+
<h3>Ticket unavailable</h3>
|
|
334
|
+
<p className="form-alert">{ticketQuery.error?.message ?? 'The ticket could not be loaded.'}</p>
|
|
335
|
+
<div className="demo-row">
|
|
336
|
+
<button type="button" className="btn" onClick={() => void ticketQuery.refetch()}>Retry</button>
|
|
337
|
+
<button type="button" className="btn" onClick={close}>Close</button>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ticket = ticketQuery.data;
|
|
344
|
+
const ticketGroup = relationId(ticket.group);
|
|
345
|
+
const groupIsValid = ticketGroup == null
|
|
346
|
+
|| (typeof ticketGroup === 'number' && Number.isSafeInteger(ticketGroup) && ticketGroup > 0);
|
|
347
|
+
const uploadGroupId = typeof ticketGroup === 'number' ? ticketGroup : null;
|
|
348
|
+
const adapter = createTicketNoteAdapter(ticket.id, { groupId: ticketGroup });
|
|
349
|
+
const llmEnabled = ticket.metadata.llm_enabled === true;
|
|
350
|
+
const busy = save.isPending || enableLlm.isPending || disableLlm.isPending || pushToMaestro.isPending;
|
|
351
|
+
const patch = async (changes: Record<string, unknown>, success: string) => {
|
|
352
|
+
setError('');
|
|
353
|
+
try {
|
|
354
|
+
await save.mutateAsync({ id: ticket.id, changes });
|
|
355
|
+
await invalidateTicketDependents(queryClient, ticket.id);
|
|
356
|
+
toast.success(success);
|
|
357
|
+
} catch (cause) {
|
|
358
|
+
const message = cause instanceof Error ? cause.message : 'Ticket update failed';
|
|
359
|
+
setError(message);
|
|
360
|
+
toast.error(message);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
const runLlm = async () => {
|
|
364
|
+
setError('');
|
|
365
|
+
try {
|
|
366
|
+
const mutation = llmEnabled ? disableLlm : enableLlm;
|
|
367
|
+
await mutation.mutateAsync({ id: ticket.id, payload: {} });
|
|
368
|
+
await invalidateTicketDependents(queryClient, ticket.id);
|
|
369
|
+
toast.success(llmEnabled ? 'LLM assistance disabled' : 'LLM assistance enabled');
|
|
370
|
+
} catch (cause) {
|
|
371
|
+
const message = cause instanceof Error ? cause.message : 'LLM action failed';
|
|
372
|
+
setError(message);
|
|
373
|
+
toast.error(message);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
const push = async () => {
|
|
377
|
+
const confirmed = await modal.confirm({
|
|
378
|
+
title: 'Push ticket to Maestro?',
|
|
379
|
+
message: 'This queues a background sync. The linked item may take a few seconds to appear.',
|
|
380
|
+
confirmText: 'Queue push',
|
|
381
|
+
});
|
|
382
|
+
if (!confirmed) return;
|
|
383
|
+
setError('');
|
|
384
|
+
try {
|
|
385
|
+
await pushToMaestro.mutateAsync({ id: ticket.id, payload: {} });
|
|
386
|
+
pollDeadline.current = Date.now() + 30_000;
|
|
387
|
+
setPolling(true);
|
|
388
|
+
await linkQuery.refetch();
|
|
389
|
+
toast.info('Maestro push queued');
|
|
390
|
+
} catch (cause) {
|
|
391
|
+
const message = cause instanceof Error ? cause.message : 'Failed to queue Maestro push';
|
|
392
|
+
setError(message);
|
|
393
|
+
toast.error(message);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
const remoteUrl = safeRemoteUrl(link?.remote_url);
|
|
397
|
+
|
|
398
|
+
return (
|
|
399
|
+
<DetailView
|
|
400
|
+
icon="bi-ticket-detailed"
|
|
401
|
+
title={ticket.title}
|
|
402
|
+
subtitle={`Ticket #${ticket.id} · created ${fmt.datetime(ticket.created)} · updated ${fmt.relative(ticket.modified)}`}
|
|
403
|
+
chips={[
|
|
404
|
+
{ text: label(ticket.status), tone: statusTone(ticket.status) },
|
|
405
|
+
{ text: `P${ticket.priority}`, tone: priorityTone(ticket.priority) },
|
|
406
|
+
{ text: label(ticket.category), tone: 'muted' },
|
|
407
|
+
]}
|
|
408
|
+
onClose={close}
|
|
409
|
+
menuContext={ticket}
|
|
410
|
+
sections={[
|
|
411
|
+
{
|
|
412
|
+
key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => (
|
|
413
|
+
<div className="ticket-detail-summary">
|
|
414
|
+
<dl className="ticket-facts">
|
|
415
|
+
<div><dt>Assignee</dt><dd>{relationLabel(ticket.assignee, 'Unassigned')}</dd></div>
|
|
416
|
+
<div><dt>Group</dt><dd>{relationLabel(ticket.group, 'Global')}</dd></div>
|
|
417
|
+
<div><dt>Incident</dt><dd>{relationLabel(ticket.incident, 'None')}</dd></div>
|
|
418
|
+
<div><dt>LLM assistance</dt><dd>{llmEnabled ? 'Enabled' : 'Disabled'}</dd></div>
|
|
419
|
+
</dl>
|
|
420
|
+
<div className="ticket-description">
|
|
421
|
+
<h4>Description</h4>
|
|
422
|
+
{ticket.description ? <MarkdownView source={ticket.description} /> : <p className="dim">No description.</p>}
|
|
423
|
+
<AssistantContextLauncher model="incident.Ticket" pk={ticket.id} />
|
|
424
|
+
</div>
|
|
425
|
+
<div className="ticket-maestro-link">
|
|
426
|
+
<h4>Maestro</h4>
|
|
427
|
+
{link ? (
|
|
428
|
+
<p>{remoteUrl
|
|
429
|
+
? <a href={remoteUrl} target="_blank" rel="noreferrer">Item #{link.remote_item_id}</a>
|
|
430
|
+
: <>Item #{link.remote_item_id}</>}</p>
|
|
431
|
+
) : (
|
|
432
|
+
<p className="dim">{polling ? 'Waiting for the queued link…' : 'Not linked.'}</p>
|
|
433
|
+
)}
|
|
434
|
+
<button type="button" className="btn btn-sm" disabled={linkQuery.isFetching} onClick={() => void linkQuery.refetch()}>
|
|
435
|
+
{linkQuery.isFetching ? 'Checking…' : 'Check link'}
|
|
436
|
+
</button>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
),
|
|
440
|
+
},
|
|
441
|
+
...(canManage ? [{
|
|
442
|
+
key: 'manage', label: 'Manage', icon: 'bi-sliders', render: () => (
|
|
443
|
+
<section className="ticket-detail-controls" aria-label="Ticket controls">
|
|
444
|
+
<h4>Manage ticket</h4>
|
|
445
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
446
|
+
<div className="ticket-control-grid">
|
|
447
|
+
<label className="field"><span className="field-label">Status</span>
|
|
448
|
+
<select className="input" value={ticket.status} disabled={busy} onChange={(event) => void patch({ status: event.target.value }, 'Status updated')}>
|
|
449
|
+
{knownOptionsWithCurrent(TICKET_STATUSES, ticket.status).map((value) => <option key={value} value={value}>{label(value)}</option>)}
|
|
450
|
+
</select>
|
|
451
|
+
</label>
|
|
452
|
+
<label className="field"><span className="field-label">Category</span>
|
|
453
|
+
<select className="input" value={ticket.category} disabled={busy} onChange={(event) => void patch({ category: event.target.value }, 'Category updated')}>
|
|
454
|
+
{knownOptionsWithCurrent(TICKET_CATEGORIES, ticket.category).map((value) => <option key={value} value={value}>{label(value)}</option>)}
|
|
455
|
+
</select>
|
|
456
|
+
</label>
|
|
457
|
+
<label className="field"><span className="field-label">Priority (1–10)</span>
|
|
458
|
+
<input className={`input${priorityError ? ' input-invalid' : ''}`} type="number" min={1} max={10} step={1} value={priorityDraft} disabled={busy} onChange={(event) => { setPriorityDraft(event.target.value); setPriorityError(''); }} onBlur={() => {
|
|
459
|
+
const value = Number(priorityDraft);
|
|
460
|
+
if (!Number.isInteger(value) || value < 1 || value > 10) {
|
|
461
|
+
setPriorityError('Enter a whole number from 1 to 10');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (value !== ticket.priority) void patch({ priority: value }, 'Priority updated');
|
|
465
|
+
}} />
|
|
466
|
+
{priorityError && <span className="field-error" role="alert">{priorityError}</span>}
|
|
467
|
+
</label>
|
|
468
|
+
{canAssign && <CollectionSelect
|
|
469
|
+
endpoint="/api/user"
|
|
470
|
+
value={relationId(ticket.assignee)}
|
|
471
|
+
onChange={(next) => { if (next !== relationId(ticket.assignee)) void patch({ assignee: next }, 'Assignee updated'); }}
|
|
472
|
+
labelField="display_name"
|
|
473
|
+
valueField="id"
|
|
474
|
+
label="Assignee"
|
|
475
|
+
placeholder="Search users…"
|
|
476
|
+
disabled={busy}
|
|
477
|
+
/>}
|
|
478
|
+
</div>
|
|
479
|
+
<label className="field"><span className="field-label">Description</span>
|
|
480
|
+
<textarea className="input" rows={4} value={descriptionDraft} disabled={busy} onChange={(event) => setDescriptionDraft(event.target.value)} onBlur={() => {
|
|
481
|
+
if (descriptionDraft !== (ticket.description ?? '')) void patch({ description: descriptionDraft }, 'Description updated');
|
|
482
|
+
}} />
|
|
483
|
+
</label>
|
|
484
|
+
<div className="ticket-control-actions">
|
|
485
|
+
<button type="button" className="btn" disabled={busy} onClick={() => void runLlm()}>{llmEnabled ? 'Disable LLM' : 'Enable LLM'}</button>
|
|
486
|
+
<button type="button" className="btn" disabled={busy || isTicketTerminal(ticket.status)} onClick={() => void patch({ status: 'closed' }, 'Ticket closed')}>Close ticket</button>
|
|
487
|
+
<button type="button" className="btn" disabled={busy || Boolean(link)} onClick={() => void push()}>{pushToMaestro.isPending ? 'Queueing…' : 'Push to Maestro'}</button>
|
|
488
|
+
<button type="button" className="btn" disabled={busy} onClick={() => void openTicketEditor(ticket)}>Edit all fields</button>
|
|
489
|
+
</div>
|
|
490
|
+
</section>
|
|
491
|
+
),
|
|
492
|
+
}] : []),
|
|
493
|
+
{
|
|
494
|
+
key: 'activity', label: 'Activity', icon: 'bi-clock-history', render: () => (
|
|
495
|
+
<section className="ticket-detail-feed">
|
|
496
|
+
<RecordFeed
|
|
497
|
+
adapter={adapter}
|
|
498
|
+
variant="compact"
|
|
499
|
+
showInput={canManage}
|
|
500
|
+
currentUserId={me?.id ?? null}
|
|
501
|
+
renderAddon={(item) => <TicketActionCard item={item} ticket={ticket} canManage={canManage} />}
|
|
502
|
+
{...(groupIsValid ? { attachmentUpload: {
|
|
503
|
+
destination: uploadGroupId == null ? {} : { groupId: uploadGroupId, use: 'uploads' },
|
|
504
|
+
expectedGroupId: uploadGroupId,
|
|
505
|
+
} } : {})}
|
|
506
|
+
/>
|
|
507
|
+
</section>
|
|
508
|
+
),
|
|
509
|
+
},
|
|
510
|
+
]}
|
|
511
|
+
/>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const TICKET_FILTERS: FilterDef[] = [
|
|
516
|
+
{ key: 'status', label: 'Status', type: 'multiselect', options: selectOptions(TICKET_STATUSES) },
|
|
517
|
+
{ key: 'category', label: 'Category', type: 'multiselect', options: selectOptions(TICKET_CATEGORIES) },
|
|
518
|
+
{ key: 'assignee', label: 'Assignee ID', type: 'number', lookup: 'exact' },
|
|
519
|
+
{ key: 'priority', label: 'Minimum priority', type: 'number' },
|
|
520
|
+
];
|
|
521
|
+
|
|
522
|
+
export function TicketsPage() {
|
|
523
|
+
const { can: canManage } = useCan(TICKET_MANAGE_PERMS);
|
|
524
|
+
const openTicket = (row: TicketRow) => void modal.detail((close) => (
|
|
525
|
+
<TicketDetail ticketId={row.id} close={() => close(null)} />
|
|
526
|
+
));
|
|
527
|
+
const columns: Column<TicketRow>[] = [
|
|
528
|
+
{ key: 'id', label: 'ID', sortable: true, align: 'end', render: (row) => `#${row.id}` },
|
|
529
|
+
{
|
|
530
|
+
key: 'title', label: 'Title', sortable: true, hideable: false,
|
|
531
|
+
render: (row) => <button type="button" className="ticket-title-button" onClick={(event: MouseEvent<HTMLButtonElement>) => {
|
|
532
|
+
event.stopPropagation();
|
|
533
|
+
openTicket(row);
|
|
534
|
+
}}>{row.title}</button>,
|
|
535
|
+
},
|
|
536
|
+
{ key: 'status', label: 'Status', sortable: true, render: (row) => canManage ? <InlineTicketSelect row={row} field="status" values={TICKET_STATUSES} /> : <Badge tone={statusTone(row.status)}>{label(row.status)}</Badge> },
|
|
537
|
+
{ key: 'priority', label: 'Priority', sortable: true, align: 'center', render: (row) => <Badge tone={priorityTone(row.priority)}>P{row.priority}</Badge> },
|
|
538
|
+
{ key: 'category', label: 'Category', sortable: true, render: (row) => canManage ? <InlineTicketSelect row={row} field="category" values={TICKET_CATEGORIES} /> : label(row.category) },
|
|
539
|
+
{ key: 'assignee', label: 'Assignee', render: (row) => relationLabel(row.assignee, 'Unassigned') },
|
|
540
|
+
{ key: 'created', label: 'Created', sortable: true, render: (row) => fmt.datetime(row.created) },
|
|
541
|
+
{ key: 'modified', label: 'Updated', sortable: true, render: (row) => fmt.relative(row.modified) },
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
return (
|
|
545
|
+
<ModelTable
|
|
546
|
+
model={TicketModel}
|
|
547
|
+
columns={columns}
|
|
548
|
+
filters={TICKET_FILTERS}
|
|
549
|
+
eyebrow="Security operations"
|
|
550
|
+
title="Tickets"
|
|
551
|
+
searchPlaceholder="Search tickets…"
|
|
552
|
+
defaultParams={{ sort: '-priority', status__in: 'new,open' }}
|
|
553
|
+
columnChooser
|
|
554
|
+
persistState
|
|
555
|
+
persistKey="admin-security-tickets"
|
|
556
|
+
exportFormats={['csv', 'json']}
|
|
557
|
+
autoRefresh={30}
|
|
558
|
+
onRowClick={(row) => openTicket(row)}
|
|
559
|
+
{...(canManage ? { addLabel: 'New ticket', onAdd: () => void openTicketEditor() } : {})}
|
|
560
|
+
/>
|
|
561
|
+
);
|
|
562
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** System-pinned clauses shared by every Security Operations sibling. */
|
|
2
|
+
export const SECURITY_VIEW_PERMS = ['sys.view_security', 'sys.security'];
|
|
3
|
+
export const SECURITY_MANAGE_PERMS = ['sys.manage_security', 'sys.security'];
|
|
4
|
+
export const SECURITY_DELETE_PERMS = ['sys.manage_security'];
|
|
5
|
+
export const SECURITY_USER_LOOKUP_PERMS = ['sys.view_users', 'sys.manage_users', 'sys.users'];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Badge, DetailView, Eyebrow, FlatRow, fmt } from '../../ui';
|
|
2
|
+
import { useCan } from '../../client';
|
|
3
|
+
import {
|
|
4
|
+
SETTINGS_PERMISSIONS, SettingModel, settingGroupLabel, type SettingRow,
|
|
5
|
+
} from './model';
|
|
6
|
+
import { showSettingEditor } from './SettingEditor';
|
|
7
|
+
|
|
8
|
+
export function SettingDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
9
|
+
const { data: row, isPending, error } = SettingModel.useOne(id);
|
|
10
|
+
const { can } = useCan(SETTINGS_PERMISSIONS);
|
|
11
|
+
if (isPending) return <div className="modal-pad dim">Loading setting…</div>;
|
|
12
|
+
if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'Setting not found'}</div>;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<DetailView<SettingRow>
|
|
16
|
+
icon={row.is_secret ? 'bi-shield-lock' : 'bi-gear'}
|
|
17
|
+
title={row.key}
|
|
18
|
+
subtitle={`${settingGroupLabel(row.group)} · setting #${row.id}`}
|
|
19
|
+
chips={[
|
|
20
|
+
{ text: row.is_secret ? 'Secret' : 'Plain', tone: row.is_secret ? 'warning' : 'muted' },
|
|
21
|
+
]}
|
|
22
|
+
sections={[
|
|
23
|
+
{
|
|
24
|
+
key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
|
|
25
|
+
<>
|
|
26
|
+
<Eyebrow>Configuration</Eyebrow>
|
|
27
|
+
<FlatRow label="Key"><code>{row.key}</code></FlatRow>
|
|
28
|
+
<FlatRow label="Value">
|
|
29
|
+
<code>{row.is_secret ? row.display_value || '******' : row.display_value || '—'}</code>
|
|
30
|
+
</FlatRow>
|
|
31
|
+
<FlatRow label="Scope">{settingGroupLabel(row.group)}</FlatRow>
|
|
32
|
+
<FlatRow label="Storage">
|
|
33
|
+
<Badge tone={row.is_secret ? 'warning' : 'muted'}>{row.is_secret ? 'Secret' : 'Plain'}</Badge>
|
|
34
|
+
</FlatRow>
|
|
35
|
+
<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow>
|
|
36
|
+
<FlatRow label="Modified">{fmt.datetime(row.modified)}</FlatRow>
|
|
37
|
+
{row.is_secret && (
|
|
38
|
+
<p className="dim">
|
|
39
|
+
The stored value is write-only. Editing with a blank replacement preserves it.
|
|
40
|
+
</p>
|
|
41
|
+
)}
|
|
42
|
+
{can && (
|
|
43
|
+
<div style={{ marginTop: 16 }}>
|
|
44
|
+
<button className="btn btn-compact" onClick={() => void showSettingEditor(row)}>
|
|
45
|
+
<i className="bi bi-pencil" /> Edit setting
|
|
46
|
+
</button>
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
</>
|
|
50
|
+
),
|
|
51
|
+
},
|
|
52
|
+
]}
|
|
53
|
+
menuContext={row}
|
|
54
|
+
onClose={onClose}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|