Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.5.10

- Group receipt details by each returned SOAR action result, keeping its status, summary, and result data together without matching submitted input order. Expanded details stay with their result when returned results reorder.
- Show Mixed results and success/failure counts when an action has both outcomes, retaining SOAR's reported run status. Include failures without payloads and count reported results beyond the detail preview limit.
- Read additional action-result pages within a five-request budget. Mark incomplete result counts and show Results incomplete when omitted results could contain failures. List and receipt totals consistently count SOAR run statuses, labeled separately from individual result outcomes.
- Expand regex validation to 2,048 characters with a multiline editor and visible character count. Preserve the evaluation timeout and validate the same limit in the builder and backend.
- Add search head cluster troubleshooting for stale app-list versions, including staged files, deployer-local versions merged into member defaults, member-local overrides, and metadata reloads. Check lockfile versions alongside Splunk package metadata during packaging.

## 0.5.9

- Select existing workspace categories or add a new category in form settings.
Expand Down
26 changes: 26 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,32 @@ The REST handler supports Python 3.9 and 3.13. The app uses the signed-in Splunk

After installation, open **ActionStack** as a Splunk administrator. Existing forms, workspaces, and submissions are retained when updating the app. Refresh Splunk Web after an update to load the new browser assets.

### Search head cluster upgrades and app-list versions

Stage the complete updated app at `$SPLUNK_HOME/etc/shcluster/apps/splunk_actionstack` on the deployer, then use your normal SHC bundle deployment procedure. Installing a package into the deployer's own `etc/apps` directory alone does not update the staged bundle. ActionStack packages declare `deployer_push_mode = full`; check for environment overrides. With this mode, updated default files replace member defaults, while member-local settings take precedence during merging. [Splunk deployer documentation](https://help.splunk.com/en/splunk-enterprise/administer/distributed-search/9.3/update-search-head-cluster-members/use-the-deployer-to-distribute-apps-and-configuration-updates).

The version inside ActionStack comes from the frontend build. Splunk's app list uses the `[launcher] version` setting in `app.conf`. If they disagree, inspect the archive, staged app, and affected member rather than assuming the whole app failed to update. [Splunk app.conf reference](https://help.splunk.com/en/splunk-enterprise/administer/admin-manual/10.0/configuration-file-reference/10.0.3-configuration-file-reference/app.conf).

For the 0.5.10 package, inspect its shipped configuration:

```sh
tar -xOf splunk_actionstack-0.5.10.spl splunk_actionstack/default/app.conf
```

On each affected search head, run:

```sh
"$SPLUNK_HOME/bin/splunk" btool app list launcher --app=splunk_actionstack --debug
```

The output identifies the winning version and source file on disk. It does not guarantee that the running process has loaded that value. [Splunk btool documentation](https://help.splunk.com/en/splunk-enterprise/administer/troubleshoot/10.4/first-steps/use-btool-to-troubleshoot-configurations).

- **Old version in the member's `default/app.conf`:** this is not a browser-cache issue. Compare it with the package and the deployer's staged `default/app.conf`. Also inspect `etc/shcluster/apps/splunk_actionstack/local/app.conf` on the **deployer**, even if the member has no local file. In `merge_to_default` mode, a stale deployer-local version overrides the staged default and is written into the member's default file, while updated frontend assets can still deploy. If both staged copies are correct, check deployment status and the generated bundle. [Splunk merge behavior](https://help.splunk.com/en/splunk-enterprise/administer/distributed-search/9.3/update-search-head-cluster-members/use-the-deployer-to-distribute-apps-and-configuration-updates).
- **Old version from `local/app.conf`:** a local override is masking the package. Have the cluster administrator back up the named file and remove only the stale `[launcher] version` override through the appropriate cluster configuration procedure. Also inspect the deployer's staged `local/app.conf` so later pushes cannot reintroduce it. Removing it only on the deployer will not clear an existing member-local override; a new package cannot override that setting either. Preserve other local settings.
- **Correct version on disk, old version in Splunk Web:** verify which member served the page, complete any required cluster rolling restart, then reload Splunk Web. Manual changes to `app.conf` require a Splunk restart. [Splunk app.conf reference](https://help.splunk.com/en/splunk-enterprise/administer/admin-manual/10.0/configuration-file-reference/10.0.3-configuration-file-reference/app.conf).

Keep release version metadata in `default/app.conf`; do not copy the whole file into `local/app.conf` to customize another setting. Release packages exclude `local/` and never rewrite cluster-local overrides automatically.

## Workspace setup

The setup wizard creates the first workspace. Select **Create roles in Splunk** to provision:
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@ ActionStack is an AI-driven project attempting to build something useful for the
- Team workspaces with access controlled by Splunk roles.
- Automation catalog with search, categories, favorites, and pagination.
- Form builder with conditional fields, validation, lookup inputs, multiple-value inputs, and collapsible sections.
- Regex validation supports patterns up to 2,048 characters and matches the entire field value. Use `(?x)` when formatting a pattern across lines.
- Drafts, publishing, version history, cloning, and recoverable form deletion.
- Reusable workspace categories and JSON form export/import.
- Configurable SOAR labels, tags, CEF mappings, and approval requirements handled by your playbooks.
- Paginated submission history with playbook/action status counts, delivery retries, and receipts with custom action names, reported block results, summaries, and data.
- Paginated submission history with playbook/action status counts, delivery retries, and receipts with custom action names, reported block results, and separate groups for each returned action result’s summary and data. Actions with both successful and failed results show Mixed results alongside SOAR’s reported run status. Result grouping does not depend on input or completion order.
- Optional receipt timeline and JSON receipt / CSV submission exports for audit review.
- Optional retention for delivered submissions, with SOAR events retained.
- Light, dark, and system themes.

Submission and receipt group counts reflect **SOAR run statuses**, which can differ from individual action-result outcomes. Receipts read additional result pages within a five-request budget and display up to five result details per action. When coverage is incomplete, counts are marked partial and the action shows **Results incomplete** unless mixed outcomes are already confirmed. Open SOAR for the complete results.

## Installation

ActionStack runs on a standalone Splunk Enterprise search head or a search head cluster, with KV Store enabled.
Expand Down
87 changes: 87 additions & 0 deletions frontend/src/ActionResults.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { keyedActionResults } from "./action-results.js";
import { runStatusLabel } from "./run-status.js";
import type { RunGroup } from "./types";

function resultText(value: unknown) {
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
}

export function ActionResults({ run }: { run: RunGroup["items"][number] }) {
const results = run.summaries || [];
const partial = run.result_counts_complete === false;
if (!results.length && !partial) return null;
const counts =
run.result_counts ||
results.reduce<Record<string, number>>((outcomes, result) => {
outcomes[result.status] = (outcomes[result.status] || 0) + 1;
return outcomes;
}, {});
const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
return (
<div className="actionstack-action-results">
<p className="actionstack-result-counts">
{partial ? "Partial action result counts" : "Reported action results"}:{" "}
{Object.entries(counts)
.filter(([, count]) => count > 0)
.map(
([status, count]) =>
`${count} ${runStatusLabel(status).toLowerCase()}`,
)
.join(" · ") || "No results loaded"}
</p>
{partial && (
<p className="muted">
Loaded {total}
{run.result_total != null ? ` of ${run.result_total}` : ""} results.
Counts are incomplete; additional failures may be unreported. Open
SOAR for all outcomes.
</p>
)}
{keyedActionResults(results).map(({ result, key }, i) => (
<section
className="actionstack-action-result"
aria-label={`Action result ${i + 1}: ${runStatusLabel(result.status)}, app run ${result.app_run_id}`}
key={key}
>
<div className="actionstack-result-heading">
<div>
<b>Action result {i + 1}</b>
<small>App run #{result.app_run_id}</small>
</div>
<span className={`run-status run-${result.status}`}>
{runStatusLabel(result.status)}
</span>
</div>
{result.summary !== undefined && (
<details className="actionstack-run-summary">
<summary>Action summary</summary>
<pre>{resultText(result.summary)}</pre>
</details>
)}
{result.data !== undefined && (
<details className="actionstack-run-summary">
<summary>Result data</summary>
<pre>{resultText(result.data)}</pre>
{result.data_truncated && (
<p className="muted">
Showing a limited preview. Open SOAR for complete results.
</p>
)}
</details>
)}
{result.summary === undefined && result.data === undefined && (
<p className="muted">
No summary or result data reported for this result.
</p>
)}
</section>
))}
{total > results.length && (
<p className="muted">
Showing details for {results.length} of {total} reported action
results. Open SOAR for all results.
</p>
)}
</div>
);
}
78 changes: 26 additions & 52 deletions frontend/src/AutomationActivity.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Fragment, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { RefreshCw } from "lucide-react";
import { RunCounts } from "./RunCounts";
import { ActionResults } from "./ActionResults";
import { api } from "./api";
import { ReceiptTimeline } from "./ReceiptTimeline";
import { runStatusLabel } from "./run-status.js";
import type { ActivitySnapshot } from "./receipt-audit.js";
import type { Activity, RunGroup, Submission } from "./types";

function Runs({ title, group }: { title: string; group: RunGroup }) {
export function Runs({ title, group }: { title: string; group: RunGroup }) {
return (
<div className="activity-group">
<h4>
Expand All @@ -30,57 +32,29 @@ function Runs({ title, group }: { title: string; group: RunGroup }) {
)}
{group.items.map((run) => (
<div className="activity-run" key={run.id}>
<div>
<b>{run.name}</b>
<small>
{run.block_type || `Run #${run.id}`}
{run.action && run.action !== run.name
? ` · ${run.action}`
: ""}
{run.playbook_run_id
? ` · Playbook run #${run.playbook_run_id}`
: ""}
</small>
{run.summaries?.map((s, i) => (
<Fragment key={s.app_run_id + ":" + i}>
{s.summary !== undefined && (
<details className="actionstack-run-summary">
<summary>
Action summary · {s.status} · app run #{s.app_run_id}
</summary>
<pre>
{typeof s.summary === "string"
? s.summary
: JSON.stringify(s.summary, null, 2)}
</pre>
</details>
)}
{s.data !== undefined && (
<details className="actionstack-run-summary">
<summary>
Result data · {s.status} · app run #{s.app_run_id}
</summary>
<pre>
{typeof s.data === "string"
? s.data
: JSON.stringify(s.data, null, 2)}
</pre>
{s.data_truncated && (
<p className="muted">
Showing a limited preview. Open SOAR for complete
results.
</p>
)}
</details>
)}
</Fragment>
))}
<div className="activity-run-heading">
<div>
<b>{run.name}</b>
<small>
{run.block_type || `Run #${run.id}`}
{run.action && run.action !== run.name
? ` · ${run.action}`
: ""}
{run.playbook_run_id
? ` · Playbook run #${run.playbook_run_id}`
: ""}
</small>
{run.reported_status && (
<small>
SOAR run status: {runStatusLabel(run.reported_status)}
</small>
)}
</div>
<span className={`run-status run-${run.status}`}>
{runStatusLabel(run.status)}
</span>
</div>
<span className={`run-status run-${run.status}`}>
{run.status === "success"
? "Succeeded"
: run.status.charAt(0).toUpperCase() + run.status.slice(1)}
</span>
<ActionResults run={run} />
</div>
))}
{group.notice && <p className="muted">{group.notice}</p>}
Expand Down
37 changes: 25 additions & 12 deletions frontend/src/FieldValidation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,27 +70,40 @@ export function FieldValidation({
{!["ip_address", "domain", "sha1", "sha256"].includes(r.operator) && (
<label className="field">
Value
<input
value={String(r.value ?? "")}
maxLength={r.operator === "regex" ? 512 : 200}
placeholder={
r.operator === "like" || r.operator === "not_like"
? "e.g. *@example.com"
: r.operator === "regex"
? "e.g. INC-[0-9]+"
{r.operator === "regex" ? (
<textarea
value={String(r.value ?? "")}
maxLength={2048}
rows={4}
spellCheck={false}
placeholder="e.g. INC-[0-9]+"
onChange={(e) => update(i, { value: e.target.value })}
/>
) : (
<input
value={String(r.value ?? "")}
maxLength={200}
placeholder={
r.operator === "like" || r.operator === "not_like"
? "e.g. *@example.com"
: field.type === "checkbox"
? "true or false"
: "Value to compare"
}
onChange={(e) => update(i, { value: e.target.value })}
/>
}
onChange={(e) => update(i, { value: e.target.value })}
/>
)}
{["like", "not_like"].includes(r.operator) && (
<small>
Use * for any text and ? for one character. Case-sensitive.
</small>
)}
{r.operator === "regex" && (
<small>Matches the whole value. Use (?i) to ignore case.</small>
<small>
{String(r.value ?? "").length.toLocaleString()} / 2,048
characters. Matches the whole value. Use (?i) to ignore case;
(?x) for a pattern formatted across lines.
</small>
)}
</label>
)}
Expand Down
6 changes: 2 additions & 4 deletions frontend/src/ReceiptTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { receiptTimeline } from "./receipt-audit.js";
import { runStatusLabel } from "./run-status.js";
import type { Activity, Submission } from "./types";

export function ReceiptTimeline({
Expand Down Expand Up @@ -62,10 +63,7 @@ export function ReceiptTimeline({
<span className={`run-status run-${row.status}`}>
{row.status === "submitted"
? "Delivered"
: row.status === "success"
? "Succeeded"
: row.status.charAt(0).toUpperCase() +
row.status.slice(1).replaceAll("_", " ")}
: runStatusLabel(row.status)}
</span>
</div>
<small className="actionstack-timeline-reference">
Expand Down
23 changes: 15 additions & 8 deletions frontend/src/RunCounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { RunGroup } from "./types";

export type CountGroup = Pick<
RunGroup,
"counts" | "total" | "truncated" | "error"
"counts" | "counts_basis" | "total" | "truncated" | "error"
>;

export function RunCounts({
Expand All @@ -27,6 +27,11 @@ export function RunCounts({
<b>
{group.total} <span className="muted">total</span>
</b>
{group.counts_basis === "soar_run_status" && (
<small title="SOAR's reported run statuses. Individual action results can have different outcomes; open the receipt for details.">
SOAR run statuses
</small>
)}
{Object.entries(group.counts || {}).map(
([status, count]) =>
count > 0 && (
Expand All @@ -39,13 +44,15 @@ export function RunCounts({
? "✓"
: status === "failed"
? "✕"
: status === "running"
? "↻"
: status === "pending"
? "◷"
: status === "cancelled"
? "−"
: "?"}{" "}
: status === "mixed"
? "◐"
: status === "running"
? "↻"
: status === "pending"
? "◷"
: status === "cancelled"
? "−"
: "?"}{" "}
{count}
<span className="sr-only"> {status}</span>
</span>
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/action-results.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Keep disclosure state with the returned result when asynchronous results
* arrive in a different order. SOAR does not always report an action-result ID.
* Identical results remain separate groups rather than being deduplicated.
* @param {import('./types').ActionResult[]} results */
export function keyedActionResults(results) {
const occurrences = new Map();
return results.map((result) => {
const signature = JSON.stringify({
app_run_id: result.app_run_id,
status: result.status,
summary: result.summary,
data: result.data,
data_truncated: result.data_truncated,
});
const occurrence = occurrences.get(signature) || 0;
occurrences.set(signature, occurrence + 1);
return { result, key: `${signature}:${occurrence}` };
});
}
Loading
Loading