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.
Files changed (70) hide show
  1. package/docs/DetailView.md +19 -15
  2. package/docs/README.md +1 -0
  3. package/docs/admin-assistant.md +3 -1
  4. package/docs/admin-credentials.md +7 -0
  5. package/docs/admin-dashboard.md +1 -1
  6. package/docs/admin-dns.md +8 -2
  7. package/docs/admin-incidents.md +26 -3
  8. package/docs/admin-jobs.md +7 -4
  9. package/docs/admin-messaging.md +6 -5
  10. package/docs/admin-modals.md +122 -0
  11. package/docs/admin-network-security.md +4 -4
  12. package/docs/admin-phonehub.md +13 -5
  13. package/docs/admin-push.md +7 -0
  14. package/docs/admin-rules.md +8 -0
  15. package/docs/admin-shortlinks.md +2 -1
  16. package/docs/admin-storage.md +2 -1
  17. package/docs/auth.md +28 -0
  18. package/docs/client.md +12 -0
  19. package/docs/feedback.md +10 -0
  20. package/docs/forms.md +10 -0
  21. package/docs/popover.md +15 -0
  22. package/package.json +1 -1
  23. package/src/admin/assistant/api.ts +1 -1
  24. package/src/admin/assistant/pages.tsx +71 -17
  25. package/src/admin/assistant/skill-lifecycle.ts +9 -0
  26. package/src/admin/core/index.ts +7 -2
  27. package/src/admin/credentials/group-api-keys.tsx +47 -43
  28. package/src/admin/credentials/webhook-subscriptions.tsx +28 -40
  29. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  30. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  31. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  32. package/src/admin/dns/setup.ts +1 -1
  33. package/src/admin/domains/observability.ts +1 -1
  34. package/src/admin/identity/users/UserDetail.tsx +36 -20
  35. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  36. package/src/admin/identity/users/sections/actions.tsx +20 -9
  37. package/src/admin/incidents/EventDetail.tsx +147 -5
  38. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  39. package/src/admin/incidents/models.ts +1 -1
  40. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  41. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  42. package/src/admin/jobs/index.ts +1 -1
  43. package/src/admin/jobs/purge-preview.ts +25 -0
  44. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  45. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  46. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  47. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  48. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  49. package/src/admin/messaging/push/PushPage.tsx +20 -8
  50. package/src/admin/monitoring/LogInspector.tsx +3 -19
  51. package/src/admin/network/IPSetDetail.tsx +32 -63
  52. package/src/admin/phonehub/PhoneHubPage.tsx +274 -13
  53. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  54. package/src/admin/phonehub/api.ts +6 -0
  55. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  56. package/src/admin/rules/condition-removal.ts +7 -0
  57. package/src/admin/security/geoip/models.ts +5 -0
  58. package/src/admin/security/tickets.tsx +40 -17
  59. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  60. package/src/admin/storage/BucketDetail.tsx +28 -9
  61. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  62. package/src/admin/storage/FileView.tsx +28 -16
  63. package/src/admin/storage/FilesPage.tsx +4 -5
  64. package/src/client/auth.ts +10 -1
  65. package/src/client/client.ts +14 -6
  66. package/src/client/mock.ts +89 -5
  67. package/src/client/upload.ts +1 -0
  68. package/src/ui/DetailView.tsx +22 -5
  69. package/src/ui/Popover.tsx +13 -12
  70. 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 { 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';
@@ -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 [preview, setPreview] = useState<PurgeResult | null>(null);
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
- setBusy(true);
67
- setPreview(null);
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({ daysOld: days, status: status || null, dryRun: true });
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
- setBusy(true);
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({ daysOld: days, status: status || null });
82
- // A REAL run reports `deleted`; only the dry run has `count`.
83
- const deleted = isPurgeDryRun(result) ? result.count : result.deleted;
84
- toast.success(`Purged ${deleted} record(s) older than ${days} day(s)`);
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
- count is the only way to know what the cutoff actually covers.
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)); setPreview(null); }}
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); setPreview(null); }}
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>