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.
- package/README.md +3 -0
- package/docs/DetailView.md +19 -15
- package/docs/ModelTable.md +26 -0
- package/docs/README.md +2 -1
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +7 -0
- package/docs/admin-dashboard.md +1 -1
- package/docs/admin-dns.md +8 -2
- package/docs/admin-incidents.md +26 -3
- package/docs/admin-jobs.md +7 -4
- package/docs/admin-messaging.md +6 -5
- package/docs/admin-modals.md +122 -0
- package/docs/admin-network-security.md +4 -4
- package/docs/admin-phonehub.md +24 -6
- package/docs/admin-push.md +7 -0
- package/docs/admin-rules.md +8 -0
- package/docs/admin-shortlinks.md +2 -1
- package/docs/admin-storage.md +2 -1
- package/docs/auth.md +28 -0
- package/docs/client.md +12 -0
- package/docs/detail-primitives.md +12 -1
- package/docs/feedback.md +70 -1
- package/docs/forms.md +51 -0
- package/docs/loading.md +3 -2
- package/docs/popover.md +18 -3
- package/package.json +1 -1
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/pages.tsx +71 -17
- package/src/admin/assistant/skill-lifecycle.ts +9 -0
- package/src/admin/core/index.ts +7 -2
- package/src/admin/credentials/group-api-keys.tsx +67 -43
- package/src/admin/credentials/webhook-subscriptions.tsx +49 -40
- package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
- package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
- package/src/admin/dns/setup.ts +1 -1
- package/src/admin/domains/observability.ts +1 -1
- package/src/admin/identity/members/MemberDetail.tsx +48 -3
- package/src/admin/identity/users/UserDetail.tsx +36 -20
- package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
- package/src/admin/identity/users/sections/actions.tsx +20 -9
- package/src/admin/incidents/EventDetail.tsx +147 -5
- package/src/admin/incidents/IncidentDetail.tsx +181 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +5 -8
- package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
- package/src/admin/jobs/index.ts +1 -1
- package/src/admin/jobs/purge-preview.ts +25 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
- package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
- package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
- package/src/admin/messaging/MailboxesPage.tsx +3 -3
- package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
- package/src/admin/messaging/push/PushPage.tsx +20 -8
- package/src/admin/monitoring/LogInspector.tsx +3 -19
- package/src/admin/network/IPSetDetail.tsx +32 -63
- package/src/admin/phonehub/PhoneHubPage.tsx +285 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +11 -5
- package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
- package/src/admin/rules/condition-removal.ts +7 -0
- package/src/admin/security/geoip/models.ts +5 -0
- package/src/admin/security/tickets.tsx +40 -17
- package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
- package/src/admin/storage/BucketDetail.tsx +28 -9
- package/src/admin/storage/FileManagerDetail.tsx +23 -5
- package/src/admin/storage/FileView.tsx +28 -16
- package/src/admin/storage/FilesPage.tsx +4 -5
- package/src/client/auth.ts +10 -1
- package/src/client/client.ts +14 -6
- package/src/client/mock.ts +90 -6
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/FormView.tsx +24 -1
- package/src/ui/ModelTable.tsx +12 -4
- package/src/ui/Popover.tsx +13 -12
- package/src/ui/detail/MetadataSection.tsx +46 -10
- package/src/ui/form-autosave.ts +134 -23
- package/src/ui/guardrail.tsx +125 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/modal.tsx +8 -4
- package/src/ui/toast.tsx +48 -5
|
@@ -1,18 +1,157 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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 =
|
|
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
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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 & 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 & 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
package/src/admin/jobs/index.ts
CHANGED
|
@@ -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
|
|
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';
|