portal-mojo 0.2.2 → 0.2.4

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.
Files changed (82) hide show
  1. package/README.md +3 -0
  2. package/docs/DetailView.md +19 -15
  3. package/docs/ModelTable.md +26 -0
  4. package/docs/README.md +2 -1
  5. package/docs/admin-assistant.md +3 -1
  6. package/docs/admin-credentials.md +7 -0
  7. package/docs/admin-dashboard.md +1 -1
  8. package/docs/admin-dns.md +8 -2
  9. package/docs/admin-incidents.md +26 -3
  10. package/docs/admin-jobs.md +7 -4
  11. package/docs/admin-messaging.md +6 -5
  12. package/docs/admin-modals.md +122 -0
  13. package/docs/admin-network-security.md +4 -4
  14. package/docs/admin-phonehub.md +24 -6
  15. package/docs/admin-push.md +7 -0
  16. package/docs/admin-rules.md +8 -0
  17. package/docs/admin-shortlinks.md +2 -1
  18. package/docs/admin-storage.md +2 -1
  19. package/docs/auth.md +28 -0
  20. package/docs/client.md +12 -0
  21. package/docs/detail-primitives.md +12 -1
  22. package/docs/feedback.md +70 -1
  23. package/docs/forms.md +51 -0
  24. package/docs/loading.md +3 -2
  25. package/docs/popover.md +18 -3
  26. package/package.json +1 -1
  27. package/src/admin/assistant/api.ts +1 -1
  28. package/src/admin/assistant/pages.tsx +71 -17
  29. package/src/admin/assistant/skill-lifecycle.ts +9 -0
  30. package/src/admin/core/index.ts +7 -2
  31. package/src/admin/credentials/group-api-keys.tsx +67 -43
  32. package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
  33. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  34. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  35. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  36. package/src/admin/dns/setup.ts +1 -1
  37. package/src/admin/domains/observability.ts +1 -1
  38. package/src/admin/identity/members/MemberDetail.tsx +48 -3
  39. package/src/admin/identity/users/UserDetail.tsx +36 -20
  40. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  41. package/src/admin/identity/users/sections/actions.tsx +20 -9
  42. package/src/admin/incidents/EventDetail.tsx +147 -5
  43. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  44. package/src/admin/incidents/models.ts +1 -1
  45. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  46. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  47. package/src/admin/jobs/index.ts +1 -1
  48. package/src/admin/jobs/purge-preview.ts +25 -0
  49. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  50. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  51. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  52. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  53. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  54. package/src/admin/messaging/push/PushPage.tsx +20 -8
  55. package/src/admin/monitoring/LogInspector.tsx +3 -19
  56. package/src/admin/network/IPSetDetail.tsx +32 -63
  57. package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
  58. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  59. package/src/admin/phonehub/api.ts +11 -5
  60. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  61. package/src/admin/rules/condition-removal.ts +7 -0
  62. package/src/admin/security/geoip/models.ts +5 -0
  63. package/src/admin/security/tickets.tsx +40 -17
  64. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  65. package/src/admin/storage/BucketDetail.tsx +28 -9
  66. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  67. package/src/admin/storage/FileView.tsx +28 -16
  68. package/src/admin/storage/FilesPage.tsx +4 -5
  69. package/src/client/auth.ts +10 -1
  70. package/src/client/client.ts +14 -6
  71. package/src/client/mock.ts +90 -6
  72. package/src/client/upload.ts +1 -0
  73. package/src/ui/DetailView.tsx +22 -5
  74. package/src/ui/FormView.tsx +24 -1
  75. package/src/ui/ModelTable.tsx +12 -4
  76. package/src/ui/Popover.tsx +13 -12
  77. package/src/ui/detail/MetadataSection.tsx +46 -10
  78. package/src/ui/form-autosave.ts +134 -23
  79. package/src/ui/guardrail.tsx +125 -0
  80. package/src/ui/index.ts +1 -0
  81. package/src/ui/modal.tsx +8 -4
  82. package/src/ui/toast.tsx +48 -5
@@ -1,18 +1,157 @@
1
- import { DetailView, Eyebrow, FlatRow, KnownFieldsCard, StatusPanel, fmt } from '../../ui';
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { mojoCall } from '../../client/runtime';
3
+ import { DetailView, Eyebrow, FlatRow, JsonBlock, KnownFieldsCard, StatusPanel, fmt, type KnownField } from '../../ui';
2
4
  import { EvidenceCard, INCIDENT_METADATA_FIELDS, RequestResponseForensics, TraceForensics, first, metadataOf } from './forensics';
3
- import { showIncidentDetail, useEventDetail } from './models';
5
+ import { sanitizeEventRow } from './sanitize';
6
+ import { EventModel, showIncidentDetail, type EventRow } from './models';
7
+
8
+ const MOJOSEC_SUMMARY_FIELDS: KnownField[] = [
9
+ { key: 'kind', label: 'Detection kind', hideEmpty: true },
10
+ { key: 'count', label: 'Occurrences', hideEmpty: true },
11
+ { key: 'event_id', label: 'Sensor event ID', hideEmpty: true },
12
+ { key: 'sensor_id', label: 'Sensor', hideEmpty: true },
13
+ { key: 'sensor_severity', label: 'Sensor severity', hideEmpty: true },
14
+ { key: 'effective_level', label: 'Effective level', hideEmpty: true },
15
+ { key: 'recommendation', label: 'Recommendation', hideEmpty: true },
16
+ { key: 'first_seen', label: 'First seen', format: 'datetime', hideEmpty: true },
17
+ { key: 'last_seen', label: 'Last seen', format: 'datetime', hideEmpty: true },
18
+ { key: 'protocol_version', label: 'Protocol version', hideEmpty: true },
19
+ { key: 'installation_key_id', label: 'Installation key ID', hideEmpty: true },
20
+ { key: 'sensor_policy_revision_sha256', label: 'Sensor policy revision', hideEmpty: true },
21
+ { key: 'expected_change', label: 'Expected change', hideEmpty: true },
22
+ ];
23
+
24
+ const MOJOSEC_EVIDENCE_FIELDS: KnownField[] = [
25
+ { key: 'actor', label: 'Actor', hideEmpty: true },
26
+ { key: 'target_user', label: 'Target user', hideEmpty: true },
27
+ { key: 'user', label: 'User', hideEmpty: true },
28
+ { key: 'auth_method', label: 'Authentication method', hideEmpty: true },
29
+ { key: 'service', label: 'Service', hideEmpty: true },
30
+ { key: 'attribution', label: 'Attribution', hideEmpty: true },
31
+ { key: 'command', label: 'Command', hideEmpty: true },
32
+ { key: 'command_path', label: 'Command path', hideEmpty: true },
33
+ { key: 'command_family', label: 'Command family', hideEmpty: true },
34
+ { key: 'command_truncated', label: 'Command truncated', hideEmpty: true },
35
+ { key: 'command_path_truncated', label: 'Command path truncated', hideEmpty: true },
36
+ { key: 'cwd', label: 'Working directory', hideEmpty: true },
37
+ { key: 'cwd_truncated', label: 'Working directory truncated', hideEmpty: true },
38
+ { key: 'proof_status', label: 'Proof status', hideEmpty: true },
39
+ { key: 'producer_comm', label: 'Producer process', hideEmpty: true },
40
+ { key: 'producer_exe', label: 'Producer executable', hideEmpty: true },
41
+ { key: 'producer_pid', label: 'Producer PID', hideEmpty: true },
42
+ { key: 'producer_uid', label: 'Producer UID', hideEmpty: true },
43
+ { key: 'target_uid', label: 'Target UID', hideEmpty: true },
44
+ { key: 'opener_uid', label: 'Opener UID', hideEmpty: true },
45
+ { key: 'audit_session', label: 'Audit session', hideEmpty: true },
46
+ { key: 'audit_loginuid', label: 'Audit login UID', hideEmpty: true },
47
+ { key: 'tty', label: 'TTY', hideEmpty: true },
48
+ { key: 'boot_id', label: 'Boot ID', hideEmpty: true },
49
+ { key: 'monotonic', label: 'Monotonic timestamp', hideEmpty: true },
50
+ { key: 'systemd_unit', label: 'systemd unit', hideEmpty: true },
51
+ { key: 'cgroup', label: 'Control group', hideEmpty: true },
52
+ { key: 'selinux', label: 'SELinux context', hideEmpty: true },
53
+ { key: 'job_function', label: 'Job function', hideEmpty: true },
54
+ { key: 'origin_kind', label: 'Origin kind', hideEmpty: true },
55
+ { key: 'operation_id', label: 'Operation ID', hideEmpty: true },
56
+ { key: 'execution_id', label: 'Execution ID', hideEmpty: true },
57
+ { key: 'job_id', label: 'Job ID', hideEmpty: true },
58
+ { key: 'lineage_sha256', label: 'Lineage digest', hideEmpty: true },
59
+ { key: 'receipt_semantics', label: 'Receipt semantics', hideEmpty: true },
60
+ { key: 'ancestors', label: 'Process ancestry', hideEmpty: true },
61
+ { key: 'peer_ip', label: 'Peer IP', hideEmpty: true },
62
+ { key: 'method', label: 'HTTP method', hideEmpty: true },
63
+ { key: 'host', label: 'HTTP host', hideEmpty: true },
64
+ { key: 'status', label: 'HTTP status', hideEmpty: true },
65
+ { key: 'response_class', label: 'Response class', hideEmpty: true },
66
+ { key: 'resource_id', label: 'Resource ID', hideEmpty: true },
67
+ { key: 'edge_policy_version', label: 'Edge policy version', hideEmpty: true },
68
+ { key: 'path', label: 'Request path', hideEmpty: true },
69
+ { key: 'upstream_status', label: 'Upstream status', hideEmpty: true },
70
+ { key: 'request_id', label: 'Request ID', hideEmpty: true },
71
+ { key: 'scheme', label: 'Scheme', hideEmpty: true },
72
+ { key: 'protocol', label: 'Protocol', hideEmpty: true },
73
+ { key: 'tls_protocol', label: 'TLS protocol', hideEmpty: true },
74
+ { key: 'tls_cipher', label: 'TLS cipher', hideEmpty: true },
75
+ { key: 'remote_port', label: 'Remote port', hideEmpty: true },
76
+ { key: 'peer_port', label: 'Peer port', hideEmpty: true },
77
+ { key: 'server_port', label: 'Server port', hideEmpty: true },
78
+ { key: 'request_length', label: 'Request bytes', hideEmpty: true },
79
+ { key: 'response_bytes', label: 'Response bytes', hideEmpty: true },
80
+ { key: 'response_body_bytes', label: 'Response body bytes', hideEmpty: true },
81
+ { key: 'request_time_ms', label: 'Request time (ms)', hideEmpty: true },
82
+ { key: 'upstream_connect_time_ms', label: 'Upstream connect time (ms)', hideEmpty: true },
83
+ { key: 'upstream_header_time_ms', label: 'Upstream header time (ms)', hideEmpty: true },
84
+ { key: 'upstream_response_time_ms', label: 'Upstream response time (ms)', hideEmpty: true },
85
+ { key: 'upstream_response_length', label: 'Upstream response bytes', hideEmpty: true },
86
+ { key: 'upstream_bytes_received', label: 'Upstream bytes received', hideEmpty: true },
87
+ { key: 'upstream_bytes_sent', label: 'Upstream bytes sent', hideEmpty: true },
88
+ { key: 'referrer_origin', label: 'Referrer origin', hideEmpty: true },
89
+ { key: 'referrer', label: 'Referrer', hideEmpty: true },
90
+ { key: 'user_agent', label: 'User agent', hideEmpty: true },
91
+ { key: 'last_occurrence_sample', label: 'Last occurrence sample', hideEmpty: true },
92
+ { key: 'unit', label: 'Unit', hideEmpty: true },
93
+ { key: 'failure_kind', label: 'Failure kind', hideEmpty: true },
94
+ ];
95
+
96
+ function record(value: unknown): Record<string, unknown> | null {
97
+ return value != null && typeof value === 'object' && !Array.isArray(value)
98
+ ? value as Record<string, unknown>
99
+ : null;
100
+ }
101
+
102
+ export function extractMojosecDetails(metadata: Record<string, unknown>): {
103
+ summary: Record<string, unknown>;
104
+ evidence: Record<string, unknown>;
105
+ } | null {
106
+ const mojosec = record(metadata.mojosec);
107
+ if (!mojosec) return null;
108
+ const { evidence: rawEvidence, ...summary } = mojosec;
109
+ return { summary, evidence: record(rawEvidence) ?? {} };
110
+ }
111
+
112
+ export function MojosecEventSection({ details }: {
113
+ details: NonNullable<ReturnType<typeof extractMojosecDetails>>;
114
+ }) {
115
+ return <>
116
+ <Eyebrow>Detection and sensor</Eyebrow>
117
+ <KnownFieldsCard data={details.summary} known={MOJOSEC_SUMMARY_FIELDS} showRaw={false} emptyText="No MojoSec detection summary was recorded." />
118
+ <Eyebrow>Validated evidence</Eyebrow>
119
+ <KnownFieldsCard data={details.evidence} known={MOJOSEC_EVIDENCE_FIELDS} showRaw={false} emptyText="No projected evidence was recorded." />
120
+ </>;
121
+ }
122
+
123
+ function displayValue(value: unknown): string {
124
+ if (value == null || value === '') return '—';
125
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
126
+ try { return JSON.stringify(value); } catch { return String(value); }
127
+ }
4
128
 
5
129
  function incidentId(value: unknown): number | null {
6
130
  if (typeof value === 'number') return value;
7
131
  if (value && typeof value === 'object' && typeof (value as { id?: unknown }).id === 'number') return (value as { id: number }).id;
8
132
  return null;
9
133
  }
134
+
135
+ function prepareEventDetailRecord(rawEvent: EventRow): { rawEvent: EventRow; event: EventRow } {
136
+ return { rawEvent, event: sanitizeEventRow(rawEvent) };
137
+ }
138
+
139
+ /** Keep the authorized raw record isolated from every sanitized event cache. */
140
+ function useRawEventDetail(id: number | null) {
141
+ return useQuery({
142
+ queryKey: [EventModel.endpoint, 'raw-one', id, { graph: 'detailed' }],
143
+ queryFn: async () => (await mojoCall(`${EventModel.endpoint}/${id!}`, { params: { graph: 'detailed' } })).data as EventRow,
144
+ enabled: id != null,
145
+ });
146
+ }
147
+
10
148
  export function EventDetail({ id, onClose }: { id: number; onClose: () => void }) {
11
- const query = useEventDetail(id);
149
+ const query = useRawEventDetail(id);
12
150
  if (query.isPending) return <div className="modal-pad dim">Loading event…</div>;
13
151
  if (!query.data || query.error) return <div className="modal-pad text-bad">{query.error?.message ?? 'Event not found'}</div>;
14
- const event = query.data;
152
+ const { rawEvent, event } = prepareEventDetailRecord(query.data);
15
153
  const metadata = metadataOf(event);
154
+ const mojosec = extractMojosecDetails(metadata);
16
155
  const linkedIncident = incidentId(event.incident);
17
156
  const trace = first(metadata, 'stack_trace', 'traceback');
18
157
  const hasOssec = event.category.startsWith('ossec') || metadata.alert_id != null || metadata.logfile != null;
@@ -29,13 +168,16 @@ export function EventDetail({ id, onClose }: { id: number; onClose: () => void }
29
168
  <FlatRow label="Model">{event.model_name ? `${event.model_name} #${event.model_id ?? '—'}` : '—'}</FlatRow>
30
169
  <FlatRow label="Group ID">{event.group_id ?? 'Global'}</FlatRow>
31
170
  </> },
32
- { key: 'source', label: 'Source', icon: 'bi-globe2', render: () => <><Eyebrow>Source and network</Eyebrow><FlatRow label="Source IP"><code>{event.source_ip || '—'}</code></FlatRow><FlatRow label="Hostname"><code>{event.hostname || '—'}</code></FlatRow><FlatRow label="Country">{String(event.country_code || metadata.country_code || '—')}</FlatRow><FlatRow label="Server">{String(metadata.server ?? '—')}</FlatRow></> },
171
+ { key: 'source', label: 'Source', icon: 'bi-globe2', render: () => <><Eyebrow>Source and network</Eyebrow><FlatRow label="Source IP"><code>{event.source_ip || '—'}</code></FlatRow><FlatRow label="Hostname"><code>{event.hostname || '—'}</code></FlatRow><FlatRow label="Country">{String(event.country_code || metadata.country_code || '—')}</FlatRow>{event.geo_ip != null && <FlatRow label="GeoIP">{displayValue(event.geo_ip)}</FlatRow>}<FlatRow label="Server">{String(metadata.server ?? '—')}</FlatRow></> },
33
172
  { key: 'request', label: 'Request', icon: 'bi-funnel', render: () => <RequestResponseForensics metadata={metadata} /> },
34
173
  ...(trace ? [{ key: 'trace', label: 'Stack trace', icon: 'bi-code-square', render: () => <TraceForensics metadata={metadata} /> }] : []),
35
174
  ...(hasOssec ? [{ key: 'ossec', label: 'OSSEC', icon: 'bi-shield-exclamation', render: () => <EvidenceCard metadata={metadata} /> }] : []),
175
+ ...(mojosec ? [{ key: 'mojosec', label: 'MojoSec', icon: 'bi-shield-lock', render: () => <MojosecEventSection details={mojosec} /> }] : []),
36
176
  ...(hasBouncer ? [{ key: 'bouncer', label: 'Bouncer', icon: 'bi-shield-shaded', render: () => <KnownFieldsCard data={metadata} showRaw={false} known={[{ key: 'decision', hideEmpty: true }, { key: 'risk_score', hideEmpty: true }, { key: 'page', hideEmpty: true }, { key: 'page_type', hideEmpty: true }, { key: 'muid', hideEmpty: true }, { key: 'duid', hideEmpty: true }, { key: 'triggered_signals', hideEmpty: true }]} /> }] : []),
37
177
  ...(hasPermissions ? [{ key: 'permissions', label: 'Auth evidence', icon: 'bi-key', render: () => <KnownFieldsCard data={metadata} showRaw={false} known={[{ key: 'permission_keys', hideEmpty: true }, { key: 'perms', hideEmpty: true }, { key: 'auth_result', hideEmpty: true }, { key: 'user_email', hideEmpty: true }]} /> }] : []),
38
178
  { key: 'metadata', label: 'Known fields', icon: 'bi-braces', render: () => <KnownFieldsCard data={metadata} known={[...INCIDENT_METADATA_FIELDS, { key: 'alert_id', hideEmpty: true }, { key: 'logfile', hideEmpty: true }, { key: 'error_code', hideEmpty: true }, { key: 'error_message', hideEmpty: true }]} showRaw={false} /> },
179
+ { divider: 'Raw' },
180
+ { key: 'raw', label: 'Raw data', icon: 'bi-filetype-json', render: () => <><Eyebrow>Complete API response</Eyebrow><p className="dim">The complete event record returned by Django, including all metadata.</p><JsonBlock value={rawEvent} label="Event API record" collapsible={false} /></> },
39
181
  ]}
40
182
  initialSection="overview"
41
183
  onClose={onClose}
@@ -1,25 +1,194 @@
1
- import { useQueryClient } from '@tanstack/react-query';
2
- import { createIncidentHistoryAdapter, useCan, useMe } from '../../client/runtime';
3
- import { Badge, DetailView, Eyebrow, FlatRow, KnownFieldsCard, RecordFeed, StatusPanel, fmt, toast, type Tone } from '../../ui';
1
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { createIncidentHistoryAdapter, mojoCall, useCan, useMe } from '../../client/runtime';
3
+ import { Badge, DetailView, Eyebrow, FlatRow, JsonBlock, KnownFieldsCard, RecordFeed, StatusPanel, fmt, toast, type Tone } from '../../ui';
4
4
  import { SECURITY_MANAGE_PERMS } from '../security-permissions';
5
5
  import { AssistantContextLauncher } from '../assistant/launchers';
6
- import { EvidenceCard, INCIDENT_METADATA_FIELDS, RequestResponseForensics, TraceForensics, metadataOf } from './forensics';
7
- import { INCIDENT_LIFECYCLE, IncidentModel, showEventDetail, useIncidentDetail, useIncidentEvents } from './models';
8
- import { sanitizeIncidentHistoryRow, sanitizeSecurityText } from './sanitize';
6
+ import { showGeoIpDossier, showGeoIpDossierForAddress } from '../security/geoip/GeoIpDossier';
7
+ import { blockActive, blockExpired, countryFlag, threatTone, whitelistActive, whitelistExpired, type GeoLocatedIPRow } from '../security/geoip/models';
8
+ import { EvidenceCard, INCIDENT_METADATA_FIELDS, RequestResponseForensics, TraceForensics, first, metadataOf } from './forensics';
9
+ import { INCIDENT_LIFECYCLE, IncidentModel, showEventDetail, useIncidentEvents, type IncidentRow } from './models';
10
+ import { sanitizeIncidentHistoryRow, sanitizeIncidentRow, sanitizeSecurityText } from './sanitize';
9
11
 
10
12
  function priorityTone(priority: number): Tone { return priority >= 8 ? 'danger' : priority >= 5 ? 'warning' : 'info'; }
11
13
  function statusTone(status: string): Tone { return ['resolved', 'closed'].includes(status) ? 'success' : ['new', 'open'].includes(status) ? 'danger' : status === 'investigating' ? 'warning' : 'muted'; }
12
14
 
15
+ const DASH = <span className="dim-italic">—</span>;
16
+
17
+ function asRecord(value: unknown): Record<string, unknown> {
18
+ return value != null && typeof value === 'object' && !Array.isArray(value)
19
+ ? value as Record<string, unknown> : {};
20
+ }
21
+
22
+ function scalar(value: unknown): string | null {
23
+ if (typeof value === 'string') return value.trim() ? value : null;
24
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
25
+ return null;
26
+ }
27
+
28
+ function temporal(value: unknown): string | number | null {
29
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
30
+ if (typeof value === 'string' && value.trim()) return value;
31
+ return null;
32
+ }
33
+
34
+ function display(value: unknown) {
35
+ return scalar(value) ?? DASH;
36
+ }
37
+
38
+ function Timestamp({ value, relative = true }: { value: unknown; relative?: boolean }) {
39
+ const time = temporal(value);
40
+ return time == null ? DASH : <>{fmt.datetime(time)}{relative && <span className="dim"> · {fmt.relative(time)}</span>}</>;
41
+ }
42
+
43
+ const SOURCE_FLAGS: readonly [string, keyof GeoLocatedIPRow][] = [
44
+ ['Tor', 'is_tor'], ['VPN', 'is_vpn'], ['Proxy', 'is_proxy'],
45
+ ['Cloud', 'is_cloud'], ['Datacenter', 'is_datacenter'], ['Mobile', 'is_mobile'],
46
+ ['Known attacker', 'is_known_attacker'], ['Known abuser', 'is_known_abuser'],
47
+ ];
48
+
49
+ function IncidentSourceIntelligence({ incident, metadata }: { incident: IncidentRow; metadata: Record<string, unknown> }) {
50
+ const infoRecord = asRecord(incident.ip_info);
51
+ const info = infoRecord as Partial<GeoLocatedIPRow>;
52
+ const sourceIp = scalar(incident.source_ip)
53
+ ?? scalar(first(metadata, 'source_ip', 'request_ip'))
54
+ ?? scalar(info.ip_address);
55
+ const dossierId = Number.isSafeInteger(info.id) && Number(info.id) > 0 ? Number(info.id) : null;
56
+ const openDossier = dossierId != null
57
+ ? () => showGeoIpDossier(dossierId)
58
+ : sourceIp ? () => showGeoIpDossierForAddress(sourceIp) : null;
59
+
60
+ const enforcement = {
61
+ is_blocked: info.is_blocked === true,
62
+ blocked_until: temporal(info.blocked_until) as number | null,
63
+ is_whitelisted: info.is_whitelisted === true,
64
+ whitelisted_until: temporal(info.whitelisted_until) as number | null,
65
+ };
66
+ const blocked = blockActive(enforcement);
67
+ const whitelisted = whitelistActive(enforcement);
68
+ const level = scalar(info.threat_level)?.toLowerCase() ?? '';
69
+ const risk = typeof info.risk_score === 'number' ? info.risk_score : null;
70
+ const hasAssessment = ['threat_level', 'risk_score', 'is_threat', 'is_suspicious'].some((key) => key in infoRecord);
71
+ const hasEnforcement = ['is_blocked', 'is_whitelisted', 'blocked_at', 'blocked_until', 'whitelisted_until'].some((key) => key in infoRecord);
72
+ const hasFirewall = ['firewall_generation', 'firewall_pending', 'firewall_sync_error', 'firewall_observed_at'].some((key) => key in infoRecord);
73
+ const hasObservation = ['created', 'modified', 'last_seen', 'expires_at'].some((key) => key in infoRecord);
74
+ const context = [
75
+ ['Request IP', first(metadata, 'request_ip')],
76
+ ['Server', first(metadata, 'server')],
77
+ ['Protocol', first(metadata, 'http_protocol', 'protocol')],
78
+ ['Connection ID', first(metadata, 'connection_id')],
79
+ ['User agent', first(metadata, 'http_user_agent', 'user_agent')],
80
+ ] as const;
81
+ const contextRows = context.filter(([, value]) => scalar(value) != null);
82
+ const countryCode = scalar(info.country_code) ?? scalar(incident.country_code) ?? scalar(metadata.country_code);
83
+ const countryName = scalar(info.country_name) ?? scalar(metadata.country_name);
84
+ const flag = countryFlag(countryCode);
85
+ const place = [scalar(info.city) ?? scalar(metadata.city), scalar(info.region) ?? scalar(metadata.region), countryName ?? countryCode].filter(Boolean).join(', ');
86
+ const status = blocked
87
+ ? { tone: 'danger' as const, state: 'BLOCKED', headline: scalar(info.blocked_reason) ? `Blocked: ${scalar(info.blocked_reason)}` : 'Currently blocked' }
88
+ : whitelisted
89
+ ? { tone: 'success' as const, state: 'WHITELISTED', headline: scalar(info.whitelisted_reason) ? `Whitelisted: ${scalar(info.whitelisted_reason)}` : 'Currently whitelisted' }
90
+ : hasAssessment && (info.is_threat === true || ['high', 'critical'].includes(level))
91
+ ? { tone: 'danger' as const, state: 'ALLOWED · HIGH RISK', headline: `${level || 'high'} threat assessment` }
92
+ : hasAssessment && (info.is_suspicious === true || level === 'medium')
93
+ ? { tone: 'warning' as const, state: 'ALLOWED · ELEVATED RISK', headline: 'Suspicious source signals detected' }
94
+ : hasAssessment
95
+ ? { tone: 'success' as const, state: 'ALLOWED', headline: 'No active threat signals' }
96
+ : { tone: 'info' as const, state: 'SOURCE RECORDED', headline: 'No cached IP intelligence was attached' };
97
+
98
+ return <>
99
+ <StatusPanel
100
+ tone={status.tone}
101
+ state={status.state}
102
+ headline={status.headline}
103
+ meta={`${place || sourceIp || 'Unknown source'}${risk == null ? '' : ` · risk ${risk}/100`}`}
104
+ actions={openDossier && <button type="button" className="btn btn-compact" onClick={openDossier}><i className="bi bi-box-arrow-up-right" /> Open full IP dossier</button>}
105
+ />
106
+
107
+ <Eyebrow>Location &amp; network</Eyebrow>
108
+ <FlatRow label="IP address">{sourceIp ? (openDossier ? <button type="button" className="btn-link" onClick={openDossier}><code>{sourceIp}</code></button> : <code>{sourceIp}</code>) : DASH}</FlatRow>
109
+ <FlatRow label="Hostname"><code>{incident.hostname || '—'}</code></FlatRow>
110
+ <FlatRow label="Country">{countryCode || countryName ? <>{flag && <span className="geoip-flag">{flag}</span>}{countryName || countryCode}{countryCode && <code className="dim geoip-cc">{countryCode}</code>}</> : DASH}</FlatRow>
111
+ <FlatRow label="Region">{display(info.region ?? metadata.region)}{info.region_code && <code className="dim geoip-cc">{info.region_code}</code>}</FlatRow>
112
+ <FlatRow label="City / postal">{[scalar(info.city ?? metadata.city), scalar(info.postal_code)].filter(Boolean).join(' · ') || DASH}</FlatRow>
113
+ <FlatRow label="Timezone">{display(info.timezone ?? metadata.timezone)}</FlatRow>
114
+ <FlatRow label="Coordinates">{info.latitude != null && info.longitude != null ? <code>{info.latitude}, {info.longitude}</code> : DASH}</FlatRow>
115
+ <FlatRow label="Subnet"><code>{scalar(info.subnet) || '—'}</code></FlatRow>
116
+ <FlatRow label="ASN">{display(info.asn)}</FlatRow>
117
+ <FlatRow label="ASN organization">{display(info.asn_org)}</FlatRow>
118
+ <FlatRow label="ISP">{display(info.isp)}</FlatRow>
119
+ <FlatRow label="Connection">{[scalar(info.connection_type), scalar(info.mobile_carrier)].filter(Boolean).join(' · ') || DASH}</FlatRow>
120
+
121
+ {hasAssessment && <>
122
+ <Eyebrow>Risk &amp; reputation</Eyebrow>
123
+ <FlatRow label="Threat level"><Badge tone={threatTone(info.threat_level) as Tone}>{info.threat_level || 'none'}</Badge></FlatRow>
124
+ <FlatRow label="Risk score">{risk == null ? DASH : <strong>{risk}/100</strong>}</FlatRow>
125
+ <FlatRow label="Threat"><Badge tone={info.is_threat ? 'danger' : 'muted'}>{info.is_threat ? 'Yes' : 'No'}</Badge></FlatRow>
126
+ <FlatRow label="Suspicious"><Badge tone={info.is_suspicious ? 'warning' : 'muted'}>{info.is_suspicious ? 'Yes' : 'No'}</Badge></FlatRow>
127
+ <div className="netsec-flags">
128
+ {SOURCE_FLAGS.map(([label, key]) => {
129
+ const active = info[key] === true;
130
+ return <span key={label} className={`chip ${active ? 'chip-danger' : 'chip-muted'}`}><i className={`bi ${active ? 'bi-check-lg' : 'bi-dash'}`} /> {label}</span>;
131
+ })}
132
+ </div>
133
+ </>}
134
+
135
+ {hasEnforcement && <>
136
+ <Eyebrow>Enforcement</Eyebrow>
137
+ <FlatRow label="Effective state"><Badge tone={blocked ? 'danger' : whitelisted ? 'success' : 'muted'}>{blocked ? 'Blocked' : whitelisted ? 'Whitelisted' : blockExpired(enforcement) ? 'Block expired' : whitelistExpired(enforcement) ? 'Whitelist expired' : 'Allowed'}</Badge></FlatRow>
138
+ <FlatRow label="Blocked at"><Timestamp value={info.blocked_at} /></FlatRow>
139
+ <FlatRow label="Blocked until"><Timestamp value={info.blocked_until} relative={false} /></FlatRow>
140
+ <FlatRow label="Block reason">{display(info.blocked_reason)}</FlatRow>
141
+ <FlatRow label="Block count">{typeof info.block_count === 'number' ? info.block_count : DASH}</FlatRow>
142
+ <FlatRow label="Whitelist until"><Timestamp value={info.whitelisted_until} relative={false} /></FlatRow>
143
+ <FlatRow label="Whitelist reason">{display(info.whitelisted_reason)}</FlatRow>
144
+ </>}
145
+
146
+ {hasFirewall && <>
147
+ <Eyebrow>Firewall reconciliation</Eyebrow>
148
+ <FlatRow label="State"><Badge tone={info.firewall_pending ? 'warning' : info.firewall_sync_error ? 'danger' : 'success'}>{info.firewall_pending ? 'Pending' : info.firewall_sync_error ? 'Error' : 'In sync'}</Badge></FlatRow>
149
+ <FlatRow label="Generation">{typeof info.firewall_generation === 'number' ? info.firewall_generation : DASH}</FlatRow>
150
+ <FlatRow label="Observed"><Timestamp value={info.firewall_observed_at} /></FlatRow>
151
+ <FlatRow label="Sync error">{display(info.firewall_sync_error)}</FlatRow>
152
+ </>}
153
+
154
+ {hasObservation && <>
155
+ <Eyebrow>Observation</Eyebrow>
156
+ <FlatRow label="First recorded"><Timestamp value={info.created} /></FlatRow>
157
+ <FlatRow label="Modified"><Timestamp value={info.modified} /></FlatRow>
158
+ <FlatRow label="Last seen"><Timestamp value={info.last_seen} /></FlatRow>
159
+ <FlatRow label="Cache expires"><Timestamp value={info.expires_at} relative={false} /></FlatRow>
160
+ </>}
161
+
162
+ {contextRows.length > 0 && <>
163
+ <Eyebrow>Incident context</Eyebrow>
164
+ {contextRows.map(([label, value]) => <FlatRow key={label} label={label}>{label === 'Connection ID' || label === 'Request IP' ? <code>{scalar(value)}</code> : scalar(value)}</FlatRow>)}
165
+ </>}
166
+ </>;
167
+ }
168
+
169
+ function prepareIncidentDetailRecord(rawIncident: IncidentRow): { rawIncident: IncidentRow; incident: IncidentRow } {
170
+ return { rawIncident, incident: sanitizeIncidentRow(rawIncident) };
171
+ }
172
+
173
+ /** Keep the authorized raw record isolated from every sanitized incident cache. */
174
+ function useRawIncidentDetail(id: number | null) {
175
+ return useQuery({
176
+ queryKey: [IncidentModel.endpoint, 'raw-one', id, { graph: 'detailed' }],
177
+ queryFn: async () => (await mojoCall(`${IncidentModel.endpoint}/${id!}`, { params: { graph: 'detailed' } })).data as IncidentRow,
178
+ enabled: id != null,
179
+ });
180
+ }
181
+
13
182
  export function IncidentDetail({ id, onClose }: { id: number; onClose: () => void }) {
14
183
  const queryClient = useQueryClient();
15
- const query = useIncidentDetail(id);
184
+ const query = useRawIncidentDetail(id);
16
185
  const events = useIncidentEvents(id);
17
186
  const save = IncidentModel.useSave();
18
187
  const { can: canManage } = useCan(SECURITY_MANAGE_PERMS);
19
188
  const { data: me } = useMe();
20
189
  if (query.isPending) return <div className="modal-pad dim">Loading incident…</div>;
21
190
  if (!query.data || query.error) return <div className="modal-pad text-bad">{query.error?.message ?? 'Incident not found'}</div>;
22
- const incident = query.data;
191
+ const { rawIncident, incident } = prepareIncidentDetailRecord(query.data);
23
192
  const groupId = Number.isSafeInteger(incident.group_id) && Number(incident.group_id) > 0
24
193
  ? Number(incident.group_id) : null;
25
194
  const adapter = createIncidentHistoryAdapter(id, {
@@ -36,6 +205,7 @@ export function IncidentDetail({ id, onClose }: { id: number; onClose: () => voi
36
205
  } catch (error) { toast.error(error instanceof Error ? error.message : 'Status update failed'); }
37
206
  };
38
207
  const trace = metadata.stack_trace ?? metadata.traceback;
208
+ const hasSource = Boolean(scalar(incident.source_ip) || Object.keys(asRecord(incident.ip_info)).length || scalar(first(metadata, 'source_ip', 'request_ip')));
39
209
  return <DetailView
40
210
  icon="bi-shield-exclamation"
41
211
  title={incident.title || `Incident #${incident.id}`}
@@ -55,12 +225,15 @@ export function IncidentDetail({ id, onClose }: { id: number; onClose: () => voi
55
225
  <FlatRow label="Hostname"><code>{incident.hostname || '—'}</code></FlatRow>
56
226
  <FlatRow label="Model">{incident.model_name ? `${incident.model_name} #${incident.model_id ?? '—'}` : '—'}</FlatRow>
57
227
  </> },
228
+ ...(hasSource ? [{ key: 'source', label: 'Source', icon: 'bi-globe2', render: () => <IncidentSourceIntelligence incident={incident} metadata={metadata} /> }] : []),
58
229
  { key: 'evidence', label: 'Evidence', icon: 'bi-funnel', render: () => <EvidenceCard metadata={{ ...metadata, model_name: incident.model_name, model_id: incident.model_id }} /> },
59
230
  { key: 'events', label: 'Events', icon: 'bi-list-ul', render: () => events.isPending ? <p className="dim">Loading events…</p> : events.data?.rows.length ? <div className="incident-events-list">{events.data.rows.map((event) => <button type="button" className="incident-event-row" key={event.id} onClick={() => showEventDetail(event.id)}><Badge tone={event.level >= 8 ? 'danger' : event.level >= 4 ? 'warning' : 'muted'}>L{event.level}</Badge><span><strong>{event.title || event.category}</strong><small>{fmt.datetime(event.created)} · {event.details || 'No details'}</small></span></button>)}</div> : <p className="dim-italic">No events on this incident.</p> },
60
231
  { key: 'request', label: 'Request', icon: 'bi-globe2', render: () => <RequestResponseForensics metadata={metadata} /> },
61
232
  ...(trace ? [{ key: 'trace', label: 'Stack trace', icon: 'bi-code-square', render: () => <TraceForensics metadata={metadata} /> }] : []),
62
233
  { key: 'history', label: 'History', icon: 'bi-chat-left-text', render: () => <RecordFeed adapter={adapter} variant="compact" showInput={canManage} currentUserId={me?.id ?? null} placeholder="Add an incident note…" attachmentUpload={{ destination: groupId == null ? {} : { groupId, use: 'uploads' }, expectedGroupId: groupId }} /> },
63
234
  { key: 'metadata', label: 'Known fields', icon: 'bi-braces', render: () => <KnownFieldsCard data={metadata} known={[...INCIDENT_METADATA_FIELDS]} showRaw={false} emptyText="No curated forensic metadata." /> },
235
+ { divider: 'Raw' },
236
+ { key: 'raw', label: 'Raw data', icon: 'bi-filetype-json', render: () => <><Eyebrow>Complete API response</Eyebrow><p className="dim">The complete incident record returned by Django, including all metadata and IP information.</p><JsonBlock value={rawIncident} label="Incident API record" collapsible={false} /></> },
64
237
  ]}
65
238
  initialSection="overview"
66
239
  onClose={onClose}
@@ -18,7 +18,7 @@ export interface EventRow {
18
18
  source_ip: string | null; hostname: string | null; uid: number | null;
19
19
  country_code: string | null; title: string | null; details: string | null;
20
20
  model_name: string | null; model_id: number | null; metadata: Record<string, unknown>;
21
- group_id: number | null; incident?: Relation; geo_ip?: Record<string, unknown> | null;
21
+ group_id: number | null; incident?: Relation; geo_ip?: string | Record<string, unknown> | null;
22
22
  }
23
23
 
24
24
  const COMMON = new Set(['start', 'size', 'sort', 'search', 'dr_field', 'dr_start', 'dr_end']);
@@ -15,7 +15,9 @@
15
15
  // · the `version` chip — the heartbeat payload has no version field.
16
16
  // · trusted-HTML interpolation throughout — every slot here is a ReactNode.
17
17
  import { useState, type ReactNode } from 'react';
18
+ import { downloadBlob, useCan } from '../../client/runtime';
18
19
  import {
20
+ ArmedButton,
19
21
  Badge,
20
22
  DetailView,
21
23
  Eyebrow,
@@ -23,13 +25,11 @@ import {
23
25
  JsonBlock,
24
26
  KnownFieldsCard,
25
27
  StatusPanel,
26
- ArmedButton,
27
28
  fmt,
28
29
  modal,
29
30
  toast,
30
31
  type Tone,
31
32
  } from '../../ui';
32
- import { downloadBlob } from '../../client/runtime';
33
33
  import {
34
34
  BROADCAST_COMMANDS,
35
35
  broadcastCommand,
@@ -56,7 +56,6 @@ import {
56
56
  useRunnerJobLogs,
57
57
  useRunnerSysinfo,
58
58
  } from './queries';
59
- import { useCan } from '../../client/runtime';
60
59
 
61
60
  // ── Small presentational pieces ───────────────────────────────────────
62
61
 
@@ -439,6 +438,7 @@ export function RunnerDetail({ runner, onClose }: { runner: RunnerRow; onClose:
439
438
  active: activeRows.length || null,
440
439
  history: history.data?.count || null,
441
440
  }}
441
+ contextMenu={[{ label: 'Runner controls…', permissions: JOBS_MANAGE_PERMS, onSelect: () => void modal.open(close => <div className="modal-pad"><h2 className="modal-title">Runner controls</h2><ControlSection runner={runner} onClose={() => close(null)} /><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></div>) }]}
442
442
  sections={[
443
443
  {
444
444
  key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
@@ -558,11 +558,8 @@ export function RunnerDetail({ runner, onClose }: { runner: RunnerRow; onClose:
558
558
  </>
559
559
  ),
560
560
  },
561
- { divider: 'Control' },
562
- {
563
- key: 'control', label: 'Control', icon: 'bi-power',
564
- render: () => <ControlSection runner={runner} onClose={onClose} />,
565
- },
561
+
562
+
566
563
  ]}
567
564
  initialSection="overview"
568
565
  onClose={onClose}
@@ -25,11 +25,10 @@
25
25
  // tool `_tool_run_scheduled_task_now`. There is no REST route and no
26
26
  // publish endpoint to synthesize one, so the control stays ABSENT rather
27
27
  // than shipping disabled (wave-7a precedent). Tracked as django-mojo #1309.
28
- import type { ReactNode } from 'react';
29
28
  import { useQuery, useQueryClient } from '@tanstack/react-query';
29
+ import type { ReactNode } from 'react';
30
30
  import { mojoList, useCan } from '../../client/runtime';
31
31
  import {
32
- ArmedButton,
33
32
  Badge,
34
33
  DetailView,
35
34
  Eyebrow,
@@ -211,7 +210,7 @@ export function ScheduledTaskDetail({ id, onClose }: { id: string; onClose: () =
211
210
  const queryClient = useQueryClient();
212
211
  const query = ScheduledTaskModel.useOne(id);
213
212
  const save = ScheduledTaskModel.useSave();
214
- const remove = ScheduledTaskModel.useDelete();
213
+
215
214
  const { can: canManage } = useCan(SCHEDULED_TASK_MANAGE_PERMS);
216
215
 
217
216
  if (query.isPending) return <div className="modal-pad dim">Loading scheduled task…</div>;
@@ -243,16 +242,7 @@ export function ScheduledTaskDetail({ id, onClose }: { id: string; onClose: () =
243
242
  }
244
243
  };
245
244
 
246
- const destroy = async () => {
247
- try {
248
- await remove.mutateAsync({ id });
249
- await invalidate();
250
- toast.success(`Deleted ${task.name || 'scheduled task'}`);
251
- onClose();
252
- } catch (error) {
253
- toast.error(error instanceof Error ? error.message : 'Delete failed');
254
- }
255
- };
245
+
256
246
 
257
247
  const schedule = formatSchedule(task);
258
248
  const stateTone: Tone = task.enabled ? 'success' : 'muted';
@@ -271,6 +261,7 @@ export function ScheduledTaskDetail({ id, onClose }: { id: string; onClose: () =
271
261
  ...(task.run_once ? [{ icon: 'bi-1-circle', text: 'Run once', tone: 'info' as const }] : []),
272
262
  { icon: 'bi-broadcast', text: task.channel, tone: 'muted' },
273
263
  ]}
264
+ active={canManage ? { value: task.enabled, disabled: save.isPending, onChange: next => void setEnabled(next) } : undefined}
274
265
  badges={{ overview: task.last_error ? <Badge tone="danger">error</Badge> : null }}
275
266
  menuContext={task}
276
267
  contextMenu={[
@@ -283,6 +274,7 @@ export function ScheduledTaskDetail({ id, onClose }: { id: string; onClose: () =
283
274
  label: task.enabled ? 'Disable' : 'Enable',
284
275
  icon: task.enabled ? 'bi-pause-circle' : 'bi-play-circle',
285
276
  permissions: SCHEDULED_TASK_MANAGE_PERMS,
277
+ disabled: save.isPending,
286
278
  onSelect: () => { void setEnabled(!task.enabled); },
287
279
  },
288
280
  ]}
@@ -348,66 +340,8 @@ export function ScheduledTaskDetail({ id, onClose }: { id: string; onClose: () =
348
340
  },
349
341
  { key: 'config', label: 'Configuration', icon: 'bi-sliders', render: () => <ConfigurationSection task={task} /> },
350
342
  { key: 'results', label: 'Results', icon: 'bi-list-check', render: () => <ResultsSection taskId={id} /> },
351
- { divider: 'Manage' },
352
- {
353
- key: 'manage', label: 'Manage', icon: 'bi-gear', permissions: SCHEDULED_TASK_MANAGE_PERMS, render: () => (
354
- <>
355
- <Eyebrow>Operates on {task.name || 'this task'}</Eyebrow>
356
- <div className="jobs-op-row">
357
- <div className="jobs-op-label">
358
- <i className={`bi ${task.enabled ? 'bi-pause-circle' : 'bi-play-circle'}`} />
359
- {task.enabled ? 'Disable' : 'Enable'}
360
- </div>
361
- <div className="jobs-op-desc">
362
- {task.enabled
363
- ? 'The hourly dispatcher skips disabled tasks. Nothing already published is affected.'
364
- : 'The hourly dispatcher will pick this task up again on its next matching hour.'}
365
- </div>
366
- <div className="jobs-op-action">
367
- <button
368
- type="button" className="btn btn-compact" disabled={!canManage || save.isPending}
369
- onClick={() => void setEnabled(!task.enabled)}
370
- >
371
- {task.enabled ? 'Disable' : 'Enable'}
372
- </button>
373
- </div>
374
- </div>
375
343
 
376
- <div className="jobs-op-row">
377
- <div className="jobs-op-label"><i className="bi bi-pencil" /> Edit</div>
378
- <div className="jobs-op-desc">
379
- Change the schedule, notification channels and configuration. The task type is fixed
380
- after creation.
381
- </div>
382
- <div className="jobs-op-action">
383
- <button type="button" className="btn btn-compact" disabled={!canManage} onClick={() => void edit()}>
384
- Edit task…
385
- </button>
386
- </div>
387
- </div>
388
344
 
389
- <div className="danger-zone">
390
- <div className="jobs-op-row">
391
- <div className="jobs-op-label"><i className="bi bi-trash" /> Delete</div>
392
- <div className="jobs-op-desc">
393
- Removes the task and cascades to every one of its execution results. Jobs already
394
- published stay queued.
395
- </div>
396
- <div className="jobs-op-action">
397
- <ArmedButton
398
- className="btn-compact btn-danger-ghost"
399
- label="Delete task"
400
- armedLabel="Click again — the task and all its results are gone"
401
- icon="bi-trash"
402
- disabled={!canManage || remove.isPending}
403
- onConfirm={destroy}
404
- />
405
- </div>
406
- </div>
407
- </div>
408
- </>
409
- ),
410
- },
411
345
  ]}
412
346
  initialSection="overview"
413
347
  onClose={onClose}
@@ -14,7 +14,7 @@ export * from './RunnerDetail';
14
14
  export * from './sections/JobRunnersStrip';
15
15
  export * from './sections/JobChannelsPanel';
16
16
  export * from './sections/JobThroughputSection';
17
- export * from './sections/JobOperationsSection';
17
+ export { JobOperationsSection } from './sections/JobOperationsSection';
18
18
  // Stage C — scheduled tasks (plan step 10).
19
19
  export * from './ScheduledTasksPage';
20
20
  export * from './ScheduledTaskDetail';