Catalyst / admin/Synapse-Cortex 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Synapse-Cortex

public

Self Hosted ITSM Tool with RBAC/Tenanting and MFA

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Synapse-Cortex / Synapse-Cortexv2 / frontend / src / ticket / AiRemediationPanel.tsx 15279 B · main
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { ApiError } from '../api/client'
import { remediationApi } from '../api/remediation'
import type { RemediationRunOut, RemediationRunStatus, TicketOut } from '../api/types'
import { ApprovalLevelPill } from '../layout/Badges'
import { CredentialLinkSelect } from './CredentialLinkSelect'

const STATUS_LABEL: Record<RemediationRunStatus, string> = {
  investigating: 'Investigating',
  pending_approval: 'Pending Approval',
  executing: 'Executing',
  succeeded: 'Succeeded',
  failed: 'Failed',
  rejected: 'Rejected',
  blocked: 'Blocked',
}

function statusPillClass(status: RemediationRunStatus): string {
  switch (status) {
    case 'succeeded':
      return 'bg-accent-green/15 text-accent-green border-accent-green/30'
    case 'pending_approval':
    case 'executing':
    case 'investigating':
      return 'bg-accent-blue/15 text-accent-blue border-accent-blue/30'
    case 'failed':
    case 'blocked':
    case 'rejected':
      return 'bg-accent-red/15 text-accent-red border-accent-red/30'
  }
}

/** Renders below the ticket's Actions Taken log (see plan §3.5) - only when
 * the tenant's AI Remediation Module is enabled (caller in TicketDetail.tsx
 * gates this on useAuth().aiEnabled). Driven entirely by the latest
 * RemediationRun for this ticket: a PENDING_APPROVAL run always shows the
 * Approve/Reject buttons; any terminal run (or none) shows the
 * Investigate-with-AI entry point again, since a ticket can go through
 * multiple investigation cycles over its lifetime. */
export function AiRemediationPanel({ ticket }: { ticket: TicketOut }) {
  const queryClient = useQueryClient()
  const [error, setError] = useState<string | null>(null)
  const [editedCommand, setEditedCommand] = useState<string | null>(null)
  // Final "Are you sure?" gate before a live execution actually fires.
  const [confirmingLive, setConfirmingLive] = useState(false)

  const { data: runs = [] } = useQuery({
    queryKey: ['ticket', ticket.id, 'remediation', 'runs'],
    queryFn: () => remediationApi.listRuns(ticket.id),
  })
  const latestRun: RemediationRunOut | undefined = runs[0]
  // A command-runner run carries the AI's proposed free-form command, which
  // the approver may edit before executing. Seed the editable field from it.
  const isFreeform = latestRun?.suggested_command != null
  const commandValue = editedCommand ?? latestRun?.suggested_command ?? ''

  const invalidate = () => {
    queryClient.invalidateQueries({ queryKey: ['ticket', ticket.id, 'remediation', 'runs'] })
    queryClient.invalidateQueries({ queryKey: ['ticket', ticket.id, 'actions'] })
  }

  const investigateMutation = useMutation({
    mutationFn: () => remediationApi.investigate(ticket.id),
    onSuccess: invalidate,
    onError: (err) => setError(err instanceof ApiError ? err.message : 'Investigation failed.'),
  })
  const approveMutation = useMutation({
    mutationFn: () => remediationApi.approve(ticket.id, latestRun!.id, isFreeform ? commandValue : undefined),
    onSuccess: () => {
      setEditedCommand(null)
      invalidate()
    },
    onError: (err) => setError(err instanceof ApiError ? err.message : 'Approval failed.'),
  })
  const rejectMutation = useMutation({
    mutationFn: () => remediationApi.reject(ticket.id, latestRun!.id),
    onSuccess: invalidate,
    onError: (err) => setError(err instanceof ApiError ? err.message : 'Rejection failed.'),
  })
  const dryRunMutation = useMutation({
    mutationFn: () => remediationApi.dryRun(ticket.id, latestRun!.id, isFreeform ? commandValue : undefined),
    onSuccess: invalidate,
    onError: (err) => setError(err instanceof ApiError ? err.message : 'Dry run failed.'),
  })

  const busy = approveMutation.isPending || rejectMutation.isPending || dryRunMutation.isPending
  const canInvestigate = !latestRun || latestRun.status !== 'pending_approval'
  // A pending run only ever gets an execution_log from a simulation (a live run
  // is terminal), so its presence proves the plan was rehearsed - the backend
  // enforces the same rule before allowing live execution.
  const hasSimulated = (latestRun?.execution_log?.length ?? 0) > 0

  return (
    <div className="border border-cortex-border bg-cortex-panel rounded-md p-5 space-y-4">
      <div className="text-[10px] text-cortex-muted uppercase tracking-wider font-display">AI Remediation</div>

      {error && (
        <div className="text-xs border border-accent-red/40 bg-accent-red/10 text-accent-red px-3 py-2 rounded">
          {error}
        </div>
      )}

      {canInvestigate && (
        <div className="space-y-3">
          <CredentialLinkSelect ticket={ticket} />
          <button
            disabled={investigateMutation.isPending}
            onClick={() => {
              setError(null)
              investigateMutation.mutate()
            }}
            className="bg-accent-blue text-cortex-bg font-display font-bold uppercase tracking-wider text-xs px-4 py-2 rounded hover:bg-accent-blue-bright transition disabled:opacity-50"
          >
            {investigateMutation.isPending ? 'Investigating...' : 'Investigate with AI'}
          </button>
        </div>
      )}

      {latestRun && (
        <div className="border border-cortex-border bg-cortex-panel2 rounded-md p-4 space-y-3">
          <div className="flex items-center justify-between">
            <span className={`text-[10px] px-2 py-0.5 rounded uppercase border ${statusPillClass(latestRun.status)}`}>
              {STATUS_LABEL[latestRun.status]}
            </span>
            {latestRun.playbook_name_snapshot && (
              <span className="text-xs text-chrome-dim">{latestRun.playbook_name_snapshot}</span>
            )}
          </div>

          {latestRun.ai_summary && <p className="text-sm text-chrome">{latestRun.ai_summary}</p>}

          {latestRun.status === 'pending_approval' &&
            (latestRun.guardrails_snapshot?.acknowledged_dangerous_commands?.length ?? 0) > 0 && (
              <div className="text-xs border border-accent-orange/50 bg-accent-orange/10 text-accent-orange px-3 py-2 rounded">
                <span className="font-display font-bold uppercase tracking-wider text-[10px] block mb-0.5">
                   Disruptive action  review carefully
                </span>
                {latestRun.outcome_summary ||
                  'This run includes a host power-control command (e.g. reboot/shutdown). It was routed here for your explicit approval and will not run until you approve it.'}
              </div>
            )}

          {latestRun.guardrails_snapshot && (
            <div className="flex flex-wrap gap-1.5">
              <span className="text-[9px] px-1.5 py-0.5 rounded border border-cortex-border text-cortex-muted uppercase">
                OS: {latestRun.guardrails_snapshot.allowed_target_os.length > 0 ? latestRun.guardrails_snapshot.allowed_target_os.join(', ') : 'Any'}
              </span>
              <ApprovalLevelPill level={latestRun.guardrails_snapshot.required_approval_level} />
              <span className="text-[9px] px-1.5 py-0.5 rounded border border-cortex-border text-cortex-muted uppercase">
                {latestRun.guardrails_snapshot.forbidden_commands.length} forbidden pattern(s)
              </span>
            </div>
          )}

          {isFreeform && latestRun.status === 'pending_approval' && (
            <div>
              <div className="text-[9px] text-cortex-muted uppercase tracking-wider mb-1">
                AI-Suggested Command · editable before you approve
              </div>
              <textarea
                value={commandValue}
                onChange={(e) => setEditedCommand(e.target.value)}
                rows={2}
                spellCheck={false}
                className="w-full bg-cortex-bg border border-cortex-border rounded px-2 py-1.5 text-xs font-mono text-chrome focus:outline-none focus:border-accent-blue resize-y"
              />
              <div className="text-[9px] text-cortex-muted mt-1 leading-tight">
                Whatever you approve is re-checked against the forbidden-command guardrail before it runs.
              </div>
            </div>
          )}

          {!isFreeform && latestRun.proposed_plan && latestRun.proposed_plan.length > 0 && (
            <div>
              <div className="text-[9px] text-cortex-muted uppercase tracking-wider mb-1">Proposed Plan</div>
              <ol className="text-xs font-mono space-y-1 list-decimal list-inside">
                {latestRun.proposed_plan.map((step) => (
                  <li key={step.node_id} className="text-chrome">
                    <span className="text-cortex-muted">{step.label || step.node_id}:</span> {step.command}
                  </li>
                ))}
              </ol>
            </div>
          )}

          {latestRun.execution_log && latestRun.execution_log.length > 0 && (
            <div>
              <div className="text-[9px] text-cortex-muted uppercase tracking-wider mb-1">
                Execution Results
              </div>
              <div className="space-y-2 text-xs font-mono">
                {latestRun.execution_log.map((step) => (
                  <div key={step.node_id} className="border border-cortex-border rounded bg-cortex-bg">
                    <div
                      className={`flex items-center justify-between px-2 py-1 border-b border-cortex-border ${
                        step.exit_status === 0 ? 'text-accent-green' : 'text-accent-red'
                      }`}
                    >
                      <span className="truncate mr-2">
                        {step.label ? <span className="text-cortex-muted">{step.label}: </span> : null}
                        {step.command}
                      </span>
                      <span className="shrink-0">
                        exit {step.exit_status} · {step.duration_ms}ms
                      </span>
                    </div>
                    <pre className="px-2 py-1.5 text-[11px] text-chrome-dim whitespace-pre-wrap break-all max-h-48 overflow-auto">
                      {step.output?.trim() ? step.output : '(no output)'}
                    </pre>
                  </div>
                ))}
              </div>
            </div>
          )}

          {latestRun.outcome_summary && (
            <p className="text-xs text-chrome-dim border-t border-cortex-border pt-2">{latestRun.outcome_summary}</p>
          )}

          {latestRun.status === 'pending_approval' && (
            <div className="space-y-2 pt-2">
              {!hasSimulated ? (
                <div className="text-[10px] text-cortex-muted leading-tight">
                  Step 1: <span className="text-chrome font-bold">Simulate</span> the plan and review the output below.
                  Executing live on the client is only unlocked after a simulation.
                </div>
              ) : (
                <div className="text-[10px] text-accent-orange border border-accent-orange/40 bg-accent-orange/10 rounded px-2 py-1.5 leading-tight">
                  Simulation reviewed. <span className="font-bold">Execute Live on Client runs these commands on the
                  client for real over SSH.</span> This is your explicit approval to apply the fix.
                </div>
              )}
              <div className="flex flex-wrap items-center gap-3">
                <button
                  disabled={busy}
                  onClick={() => {
                    setError(null)
                    dryRunMutation.mutate()
                  }}
                  className="border border-accent-blue/50 text-accent-blue font-display font-bold uppercase tracking-wider text-xs px-4 py-2 rounded hover:bg-accent-blue/10 transition disabled:opacity-50"
                >
                  {dryRunMutation.isPending ? 'Simulating...' : hasSimulated ? 'Re-simulate' : 'Simulate'}
                </button>
                <button
                  disabled={busy || !hasSimulated}
                  title={hasSimulated ? undefined : 'Run a simulation first'}
                  onClick={() => {
                    setError(null)
                    setConfirmingLive(true)
                  }}
                  className="bg-accent-green text-cortex-bg font-display font-bold uppercase tracking-wider text-xs px-4 py-2 rounded hover:brightness-110 transition disabled:opacity-50"
                >
                  {approveMutation.isPending ? 'Executing...' : 'Execute Live on Client'}
                </button>
                <button
                  disabled={busy}
                  onClick={() => {
                    setError(null)
                    rejectMutation.mutate()
                  }}
                  className="text-xs uppercase text-accent-red hover:underline disabled:opacity-50"
                >
                  Reject
                </button>
              </div>
            </div>
          )}
        </div>
      )}

      {confirmingLive && latestRun && (
        <div
          className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
          role="dialog"
          aria-modal="true"
          onClick={() => setConfirmingLive(false)}
        >
          <div
            className="w-full max-w-md border border-accent-red/50 bg-cortex-panel rounded-md p-5 space-y-4 shadow-2xl"
            onClick={(e) => e.stopPropagation()}
          >
            <div className="text-sm font-display font-bold uppercase tracking-wider text-accent-red">
              Are you sure?  Live execution on client
            </div>
            <p className="text-sm text-chrome leading-relaxed">
              This will run{' '}
              <span className="font-bold">{latestRun.execution_log?.length} command(s)</span> on the client{' '}
              <span className="font-bold">for real over SSH</span>. It affects the live system and cannot be undone from
              Cortex.
            </p>
            <p className="text-[11px] text-cortex-muted leading-tight">
              Playbook: <span className="text-chrome-dim">{latestRun.playbook_name_snapshot}</span>. This confirmation and
              the result are recorded in the audit log against your account.
            </p>
            <div className="flex justify-end gap-3 pt-1">
              <button
                onClick={() => setConfirmingLive(false)}
                className="text-xs uppercase text-cortex-muted hover:text-chrome px-3 py-2"
              >
                Cancel
              </button>
              <button
                autoFocus
                disabled={approveMutation.isPending}
                onClick={() => {
                  setConfirmingLive(false)
                  approveMutation.mutate()
                }}
                className="bg-accent-red text-cortex-bg font-display font-bold uppercase tracking-wider text-xs px-4 py-2 rounded transition hover:brightness-110 disabled:opacity-50"
              >
                Yes, execute on client
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  )
}