portal-mojo 0.2.2 → 0.2.3
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/docs/DetailView.md +19 -15
- package/docs/README.md +1 -0
- 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 +13 -5
- 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/feedback.md +10 -0
- package/docs/forms.md +10 -0
- package/docs/popover.md +15 -0
- 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 +47 -43
- package/src/admin/credentials/webhook-subscriptions.tsx +28 -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/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 +274 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +6 -0
- 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 +89 -5
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/Popover.tsx +13 -12
- package/src/ui/modal.tsx +8 -4
|
@@ -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';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A preview is an estimate for an immutable filter tuple, never a snapshot. */
|
|
2
|
+
export interface PurgeParameters { daysOld: number; status: string | null }
|
|
3
|
+
export interface PurgePreviewIdentity { id: number; params: Readonly<PurgeParameters> }
|
|
4
|
+
export function createPurgePreviewGuard() {
|
|
5
|
+
let generation = 0;
|
|
6
|
+
let accepted: PurgePreviewIdentity | null = null;
|
|
7
|
+
let disposed = false;
|
|
8
|
+
return {
|
|
9
|
+
begin(params: PurgeParameters): PurgePreviewIdentity {
|
|
10
|
+
accepted = null;
|
|
11
|
+
return { id: ++generation, params: Object.freeze({ ...params }) };
|
|
12
|
+
},
|
|
13
|
+
accept(identity: PurgePreviewIdentity): boolean {
|
|
14
|
+
if (disposed || identity.id !== generation) return false;
|
|
15
|
+
accepted = identity;
|
|
16
|
+
return true;
|
|
17
|
+
},
|
|
18
|
+
execution(identity: PurgePreviewIdentity): Readonly<PurgeParameters> | null {
|
|
19
|
+
return !disposed && accepted === identity && identity.id === generation ? identity.params : null;
|
|
20
|
+
},
|
|
21
|
+
current(identity: PurgePreviewIdentity) { return !disposed && identity.id === generation; },
|
|
22
|
+
invalidate() { generation++; accepted = null; },
|
|
23
|
+
dispose() { disposed = true; generation++; accepted = null; },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
// · clear-queue's `confirm:"yes"` is sent only AFTER the armed confirmation.
|
|
14
14
|
// Pre-satisfying a server-side safety gate removes it.
|
|
15
15
|
// · every destructive control is an ArmedButton, not a plain button.
|
|
16
|
-
import { useState, type ReactNode } from 'react';
|
|
17
|
-
import { ArmedButton, JsonBlock, modal, toast } from '../../../ui';
|
|
16
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
18
17
|
import { useCan } from '../../../client/runtime';
|
|
18
|
+
import { ArmedButton, JsonBlock, modal, toast } from '../../../ui';
|
|
19
19
|
import {
|
|
20
20
|
cleanupConsumers,
|
|
21
21
|
clearQueue,
|
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
} from '../control';
|
|
34
34
|
import { JOBS_MANAGE_PERMS, JOB_STATUS_OPTIONS } from '../models';
|
|
35
35
|
|
|
36
|
+
import { createPurgePreviewGuard, type PurgePreviewIdentity } from '../purge-preview';
|
|
37
|
+
|
|
36
38
|
const ALL_CHANNELS = '';
|
|
37
39
|
|
|
38
40
|
function OperationRow({ label, icon, description, children, note }: {
|
|
@@ -56,63 +58,69 @@ function OperationRow({ label, icon, description, children, note }: {
|
|
|
56
58
|
|
|
57
59
|
// ── Purge dialog: preview, then arm ───────────────────────────────────
|
|
58
60
|
|
|
59
|
-
function PurgeDialog({ onClose }: { onClose: () => void }) {
|
|
61
|
+
export function PurgeDialog({ onClose, onExecuting }: { onClose: () => void; onExecuting?: (value: boolean) => void }) {
|
|
60
62
|
const [days, setDays] = useState(30);
|
|
61
63
|
const [status, setStatus] = useState('');
|
|
62
|
-
const
|
|
64
|
+
const guard = useRef(createPurgePreviewGuard());
|
|
65
|
+
const [preview, setPreview] = useState<(PurgePreviewIdentity & { result: PurgeResult }) | null>(null);
|
|
63
66
|
const [busy, setBusy] = useState(false);
|
|
64
|
-
|
|
67
|
+
const [executing, setExecuting] = useState(false);
|
|
68
|
+
const executionLock = useRef(false);
|
|
69
|
+
const mounted = useRef(false);
|
|
70
|
+
const [outcome, setOutcome] = useState<PurgeResult | null>(null);
|
|
71
|
+
const { can: canManage } = useCan(JOBS_MANAGE_PERMS);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
guard.current = createPurgePreviewGuard(); mounted.current = true;
|
|
74
|
+
return () => { mounted.current = false; guard.current.dispose(); };
|
|
75
|
+
}, []);
|
|
76
|
+
const invalidate = () => { if (executionLock.current) return; guard.current.invalidate(); setPreview(null); setBusy(false); };
|
|
65
77
|
const runPreview = async () => {
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
if (!canManage || executionLock.current) return;
|
|
79
|
+
const identity = guard.current.begin({ daysOld: Math.max(0, Math.floor(days)), status: status || null });
|
|
80
|
+
setBusy(true); setPreview(null); setOutcome(null);
|
|
68
81
|
try {
|
|
69
|
-
const result = await purgeJobs({
|
|
70
|
-
setPreview(result);
|
|
82
|
+
const result = await purgeJobs({ ...identity.params, dryRun: true });
|
|
83
|
+
if (guard.current.accept(identity)) setPreview(Object.assign(identity, { result }));
|
|
71
84
|
} catch (error) {
|
|
72
|
-
toast.error(error instanceof Error ? error.message : 'Purge preview failed');
|
|
73
|
-
} finally {
|
|
74
|
-
setBusy(false);
|
|
75
|
-
}
|
|
85
|
+
if (guard.current.current(identity)) toast.error(error instanceof Error ? error.message : 'Purge preview failed');
|
|
86
|
+
} finally { if (guard.current.current(identity)) setBusy(false); }
|
|
76
87
|
};
|
|
77
|
-
|
|
78
88
|
const runPurge = async () => {
|
|
79
|
-
|
|
89
|
+
const params = preview && guard.current.execution(preview);
|
|
90
|
+
if (!canManage || busy || executionLock.current || !params) return;
|
|
91
|
+
executionLock.current = true; onExecuting?.(true); setExecuting(true);
|
|
92
|
+
guard.current.invalidate(); setPreview(null); setBusy(true);
|
|
80
93
|
try {
|
|
81
|
-
const result = await purgeJobs({
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
onClose();
|
|
86
|
-
} catch (error) {
|
|
87
|
-
toast.error(error instanceof Error ? error.message : 'Purge failed');
|
|
88
|
-
} finally {
|
|
89
|
-
setBusy(false);
|
|
90
|
-
}
|
|
94
|
+
const result = await purgeJobs({ ...params });
|
|
95
|
+
if (mounted.current) { setOutcome(result); setPreview(null); }
|
|
96
|
+
} catch (error) { if (mounted.current) toast.error(error instanceof Error ? error.message : 'Purge failed'); }
|
|
97
|
+
finally { executionLock.current = false; onExecuting?.(false); if (mounted.current) { setExecuting(false); setBusy(false); } }
|
|
91
98
|
};
|
|
92
|
-
|
|
93
|
-
const previewCount = preview && isPurgeDryRun(preview) ? preview.count : null;
|
|
99
|
+
const previewCount = preview && isPurgeDryRun(preview.result) ? preview.result.count : null;
|
|
94
100
|
|
|
95
101
|
return (
|
|
96
102
|
<div className="modal-pad">
|
|
97
103
|
<h2 className="modal-title">Purge old jobs</h2>
|
|
98
104
|
<p className="dim">
|
|
99
105
|
Deletes job rows older than the cutoff, and their events and logs with them. Preview first — the
|
|
100
|
-
|
|
106
|
+
preview is an estimate. Execution recalculates the cutoff; deleted counts include cascaded event and log rows.
|
|
101
107
|
</p>
|
|
102
108
|
<label className="jobs-field">
|
|
103
109
|
<span>Older than (days)</span>
|
|
104
110
|
<input
|
|
105
111
|
type="number" className="input input-compact" min={0} step={1}
|
|
112
|
+
disabled={executing}
|
|
106
113
|
value={days}
|
|
107
|
-
onChange={(event) => { setDays(Math.max(0, Number(event.target.value) || 0));
|
|
114
|
+
onChange={(event) => { setDays(Math.max(0, Math.floor(Number(event.target.value) || 0))); invalidate(); }}
|
|
108
115
|
/>
|
|
109
116
|
</label>
|
|
110
117
|
<label className="jobs-field">
|
|
111
118
|
<span>Status filter</span>
|
|
112
119
|
<select
|
|
113
120
|
className="input input-compact"
|
|
121
|
+
disabled={executing}
|
|
114
122
|
value={status}
|
|
115
|
-
onChange={(event) => { setStatus(event.target.value);
|
|
123
|
+
onChange={(event) => { setStatus(event.target.value); invalidate(); }}
|
|
116
124
|
>
|
|
117
125
|
<option value="">Every status</option>
|
|
118
126
|
{JOB_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
@@ -123,23 +131,25 @@ function PurgeDialog({ onClose }: { onClose: () => void }) {
|
|
|
123
131
|
<p className={`jobs-note ${previewCount > 0 ? 'jobs-note-warn' : 'jobs-note-info'}`}>
|
|
124
132
|
<i className="bi bi-info-circle" />
|
|
125
133
|
{previewCount > 0
|
|
126
|
-
? <> <strong>{previewCount}</strong> job(s) would be deleted, created before {new Date(preview.cutoff).toLocaleString()}.</>
|
|
134
|
+
? <> <strong>{previewCount}</strong> job(s) would be deleted, created before {new Date(preview.result.cutoff).toLocaleString()}.</>
|
|
127
135
|
: <> Nothing matches — no job is older than that cutoff.</>}
|
|
128
136
|
</p>
|
|
129
137
|
)}
|
|
130
138
|
|
|
139
|
+
{outcome && <div className="jobs-note jobs-note-info"><h3>Execution result (actual cutoff)</h3><JsonBlock value={outcome} defaultOpen /></div>}
|
|
131
140
|
<div className="modal-actions">
|
|
132
|
-
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
|
141
|
+
<button type="button" className="btn" disabled={executing} onClick={() => { invalidate(); onClose(); }}>Cancel</button>
|
|
133
142
|
<button type="button" className="btn" disabled={busy} onClick={() => void runPreview()}>
|
|
134
143
|
{busy && !preview ? 'Previewing…' : 'Preview'}
|
|
135
144
|
</button>
|
|
136
145
|
<ArmedButton
|
|
146
|
+
key={preview?.id ?? 'no-preview'}
|
|
137
147
|
label="Purge"
|
|
138
148
|
armedLabel={previewCount != null ? `Click again to delete ${previewCount} job(s)` : 'Click again to purge'}
|
|
139
149
|
className="btn-danger-ghost"
|
|
140
150
|
// Arming before a preview would be arming a number nobody
|
|
141
151
|
// has seen.
|
|
142
|
-
disabled={busy || previewCount == null || previewCount === 0}
|
|
152
|
+
disabled={!canManage || busy || previewCount == null || previewCount === 0}
|
|
143
153
|
onConfirm={runPurge}
|
|
144
154
|
/>
|
|
145
155
|
</div>
|
|
@@ -208,6 +218,7 @@ export function JobOperationsSection({ channels, channelsPending, onChanged }: {
|
|
|
208
218
|
onChanged?: () => void;
|
|
209
219
|
}) {
|
|
210
220
|
const { can: canManage } = useCan(JOBS_MANAGE_PERMS);
|
|
221
|
+
const purgeExecuting = useRef(false);
|
|
211
222
|
const [channel, setChannel] = useState(ALL_CHANNELS);
|
|
212
223
|
const noChannels = !channelsPending && channels.length === 0;
|
|
213
224
|
// A stale selection must never become a request for a channel that no
|
|
@@ -368,8 +379,8 @@ export function JobOperationsSection({ channels, channelsPending, onChanged }: {
|
|
|
368
379
|
<button
|
|
369
380
|
type="button" className="btn btn-compact btn-danger-ghost" disabled={!canManage}
|
|
370
381
|
onClick={() => void modal.open((close) => (
|
|
371
|
-
<PurgeDialog onClose={() => { close(null); onChanged?.(); }} />
|
|
372
|
-
), { size: 'sm' })}
|
|
382
|
+
<PurgeDialog onExecuting={value => { purgeExecuting.current = value; }} onClose={() => { if (!purgeExecuting.current) { close(null); onChanged?.(); } }} />
|
|
383
|
+
), { size: 'sm', canDismiss: () => !purgeExecuting.current })}
|
|
373
384
|
>
|
|
374
385
|
Purge…
|
|
375
386
|
</button>
|