Add virtual Editor source (#6959)

# Description of Changes
Adds Editor source permanently available in the Sources list. Excludes
it from the Pipelines list of available sources currently because it's
not a real source on the backend, so attempting to connect to it causes
an error. It'd be nice to extend in the future to be able to set up
policies in the editor from the pipelines page, but this'll do for now.
This commit is contained in:
James Brunton
2026-07-10 13:27:40 +00:00
committed by GitHub
parent b9a7f2083b
commit 84d4455682
18 changed files with 418 additions and 51 deletions
@@ -60,7 +60,9 @@ import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse;
import stirling.software.proprietary.policy.overview.PolicyOverviewService;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.EditorSource;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceDocCounter;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
@@ -86,6 +88,7 @@ public class PolicyController {
private final PolicyStore policyStore;
private final SourceStore sourceStore;
private final SourceAccessGuard sourceAccessGuard;
private final SourceDocCounter docCounter;
private final PolicyValidator policyValidator;
private final PolicyAccessGuard policyAccessGuard;
private final PolicyManagementAuthority policyManagementAuthority;
@@ -112,9 +115,10 @@ public class PolicyController {
throws IOException {
requireRunnable(definition);
PolicyInputs inputs = toInputs(files);
String runId =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP).runId();
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
PolicyRunHandle handle =
policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP);
recordEditorDocs(inputs);
return ResponseEntity.accepted().body(new JobResponse<>(true, handle.runId(), null));
}
@PostMapping(value = "/run/stream", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -136,6 +140,7 @@ public class PolicyController {
emitter.onError(e -> log.warn("Policy run SSE emitter error", e));
PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, streamListener(emitter));
recordEditorDocs(inputs);
// whenComplete runs on the worker thread after the run finishes, so the terminal event
// never races the step events.
handle.completion()
@@ -492,6 +497,17 @@ public class PolicyController {
}
}
/**
* Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents
* feed the same virtual editor source as stored editor policies, counted against the caller's
* team. A run with no primary documents (generator pipeline) records nothing.
*/
private void recordEditorDocs(PolicyInputs inputs) {
docCounter.record(
EditorSource.counterKey(sourceAccessGuard.currentTeamId()),
inputs.primary().size());
}
/**
* Turn the typed run files into engine {@link PolicyInputs}: the primary documents plus the
* named supporting-file store, where each asset's {@code key} is the name a step references
@@ -21,6 +21,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.EditorSource;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceDocCounter;
import stirling.software.proprietary.policy.source.SourceStore;
@@ -98,10 +99,16 @@ public class PolicyRunner {
return context.outcome(runIds);
}
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
/**
* Run a stored policy on caller-supplied files (e.g. an editor upload), bypassing its sources.
* The supplied documents are still counted against the virtual {@link EditorSource}, scoped to
* the policy's team, so the Sources overview reports the whole team's editor throughput.
*/
public PolicyRunHandle runWith(
Policy policy, PolicyInputs inputs, PolicyProgressListener listener) {
return policyEngine.runPolicy(policy, inputs, listener);
PolicyRunHandle handle = policyEngine.runPolicy(policy, inputs, listener);
docCounter.record(EditorSource.counterKey(policy.teamId()), inputs.primary().size());
return handle;
}
/** Run an ad-hoc pipeline with no stored policy (AI/Automate one-offs). */
@@ -0,0 +1,27 @@
package stirling.software.proprietary.policy.source;
/**
* The Editor as a virtual, always-present source. Unlike a persisted {@link Source} it is neither
* stored nor configurable: it stands for the documents a team processes by running policies from
* the in-app editor (the {@code POST /api/v1/policies/{id}/run} path). Its throughput is tracked
* through {@link SourceDocCounter} under a synthetic, team-scoped key, so each team sees only its
* own editor activity and the client is only ever handed the opaque {@link #ID}, never a team.
*/
public final class EditorSource {
/** The single, stable id and type the client sees for the editor row. */
public static final String ID = "editor";
public static final String TYPE = "editor";
private EditorSource() {}
/**
* The per-team {@link SourceDocCounter} key. A {@code null} team (login disabled / self-hosted
* single user) shares one global bucket; otherwise counts are partitioned by team so a team's
* total aggregates every member's editor runs and no other team's.
*/
public static String counterKey(Long teamId) {
return teamId == null ? ID : ID + ":" + teamId;
}
}
@@ -34,6 +34,13 @@ public class SourceAccessGuard {
/** Team a new source is stamped with: the creator's team. {@code null} when login disabled. */
public Long teamForNewSource() {
return currentTeamId();
}
/**
* The current user's team (what scopes their sources), or {@code null} when login is disabled.
*/
public Long currentTeamId() {
return enforced() ? policyManagementAuthority.currentUserTeamId() : null;
}
@@ -88,6 +88,10 @@ public class SourceController {
"The trailing 30-day per-day document series (oldest first) for the source's"
+ " sparkline.")
public ResponseEntity<List<Long>> documentCounts(@PathVariable String sourceId) {
// The editor is virtual: its series is tracked per team, not against a persisted source.
if (EditorSource.ID.equals(sourceId)) {
return ResponseEntity.ok(overviewService.editorDailySeries());
}
return sourceStore
.get(sourceId)
.filter(sourceAccessGuard::canAccess)
@@ -104,6 +108,7 @@ public class SourceController {
+ " matching source type.")
public ResponseEntity<Source> save(@RequestBody Source source) {
requireSourceEditingAllowed();
requireNotEditor(source.id(), source.type());
Source owned = withStoredSecrets(resolveOwnership(source));
try {
validateConfig(owned);
@@ -125,6 +130,7 @@ public class SourceController {
+ " so the connection can't be pulled out from under a live policy.")
public ResponseEntity<Void> delete(@PathVariable String sourceId) {
requireSourceEditingAllowed();
requireNotEditor(sourceId, null);
Source source = sourceStore.get(sourceId).filter(sourceAccessGuard::canAccess).orElse(null);
if (source == null) {
return ResponseEntity.notFound().build();
@@ -238,6 +244,18 @@ public class SourceController {
}
}
/**
* The editor is a built-in, virtual source: it is always present and cannot be created, edited,
* or deleted like a persisted connection. Reject any attempt to touch it by id or type.
*/
private static void requireNotEditor(String id, String type) {
if (EditorSource.ID.equals(id) || EditorSource.TYPE.equals(type)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"The editor is a built-in source and cannot be created, edited, or deleted");
}
}
/** Names of the caller's visible policies that reference the given source. */
private List<String> referencingPolicyNames(String sourceId) {
return policyAccessGuard.visibleFrom(policyStore).stream()
@@ -41,7 +41,7 @@ public class SourceOverviewService {
Map<String, DocStats> docStats =
docCounter.statsFor(sources.stream().map(Source::id).toList());
List<SourceView> views =
List<SourceView> persisted =
sources.stream()
.map(
source ->
@@ -56,7 +56,13 @@ public class SourceOverviewService {
.thenComparing(SourceView::name))
.toList();
return new SourcesResponse(buildKpis(views), views);
// The editor is a built-in source: always present and pinned first. The KPI strip counts
// only the connections a team configures, so the editor is left out of the KPIs.
List<SourceView> views = new ArrayList<>();
views.add(editorView(policies));
views.addAll(persisted);
return new SourcesResponse(buildKpis(persisted), views);
}
/**
@@ -67,6 +73,49 @@ public class SourceOverviewService {
return docCounter.dailySeriesFor(sourceId);
}
/** The 30-day daily editor document series (oldest first) for the caller's team. */
public List<Long> editorDailySeries() {
return docCounter.dailySeriesFor(
EditorSource.counterKey(sourceAccessGuard.currentTeamId()));
}
/**
* The always-present editor row. It has no stored config; its documents are those the team has
* processed by running policies from the editor, and it is "used by" every policy that targets
* the editor as its source.
*/
private SourceView editorView(List<Policy> policies) {
String key = EditorSource.counterKey(sourceAccessGuard.currentTeamId());
DocStats docs = docCounter.statsFor(List.of(key)).getOrDefault(key, DocStats.ZERO);
List<SourceView.PolicyRef> refs =
policies.stream()
.filter(SourceOverviewService::runsFromEditor)
.map(policy -> new SourceView.PolicyRef(policy.id(), policy.name()))
.toList();
return new SourceView(
EditorSource.ID,
"Editor",
EditorSource.TYPE,
"active",
refs.size(),
refs,
List.of(),
docs.total(),
docs.last24h(),
docs.last30d());
}
/**
* Whether a policy runs from the editor. Editor membership is carried in the policy's output
* metadata ({@code output.options.sources}) - a client-side list the editor writes when a
* policy targets it - rather than as a persisted {@code sourceId}, because the editor is
* virtual and has no stored source to reference.
*/
private static boolean runsFromEditor(Policy policy) {
Object sources = policy.output().options().get("sources");
return sources instanceof List<?> list && list.contains(EditorSource.ID);
}
/** Policies referencing each source id, across the caller's visible policies. */
private static Map<String, List<Policy>> referencesBySource(List<Policy> policies) {
Map<String, List<Policy>> bySource = new HashMap<>();
@@ -45,7 +45,9 @@ import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.EditorSource;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceDocCounter;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
import stirling.software.proprietary.util.SecretMasker;
@@ -59,6 +61,7 @@ class PolicyControllerTest {
@Mock private stirling.software.proprietary.policy.store.PolicyStore policyStore;
@Mock private SourceStore sourceStore;
@Mock private SourceAccessGuard sourceAccessGuard;
@Mock private SourceDocCounter docCounter;
@Mock private PolicyValidator policyValidator;
@Mock private PolicyAccessGuard policyAccessGuard;
@Mock private PolicyManagementAuthority policyManagementAuthority;
@@ -92,6 +95,7 @@ class PolicyControllerTest {
policyStore,
sourceStore,
sourceAccessGuard,
docCounter,
policyValidator,
policyAccessGuard,
policyManagementAuthority,
@@ -166,6 +170,18 @@ class PolicyControllerTest {
assertThat(response.getBody().getJobId()).isEqualTo("run-1");
}
@Test
@DisplayName("feeds the editor source, scoped to the caller's team")
void adHocRunFeedsTheEditorSource() throws Exception {
when(policyRunner.runAdHoc(any(), any(), eq(PolicyProgressListener.NOOP)))
.thenReturn(handle("run-1"));
when(sourceAccessGuard.currentTeamId()).thenReturn(3L);
controller.run(definitionWithStep(), new PolicyRunFiles());
verify(docCounter).record(EditorSource.counterKey(3L), 0L);
}
@Test
@DisplayName("rejects a pipeline with no steps")
void rejectsEmptyPipeline() {
@@ -27,6 +27,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.ByteArrayResource;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.input.ResolveContext;
@@ -41,6 +42,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.EditorSource;
import stirling.software.proprietary.policy.source.InProcessSourceDocCounter;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
@@ -58,6 +60,7 @@ class PolicyRunnerTest {
@Mock private ProcessedLedger processedLedger;
private final SourceStore sourceStore = new InProcessSourceStore();
private final InProcessSourceDocCounter docCounter = new InProcessSourceDocCounter();
private PolicyRunner runner;
@BeforeEach
@@ -67,7 +70,7 @@ class PolicyRunnerTest {
policyEngine,
List.of(folderSource),
sourceStore,
new InProcessSourceDocCounter(),
docCounter,
processedLedger);
}
@@ -295,6 +298,33 @@ class PolicyRunnerTest {
verifyNoInteractions(folderSource);
}
@Test
void runWithRecordsSuppliedDocsAgainstTheEditorSourceForThePolicyTeam() {
Policy policy =
new Policy(
"p1",
"p",
"owner",
true,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
7L);
PolicyInputs inputs =
PolicyInputs.of(
List.of(
new ByteArrayResource("a".getBytes()),
new ByteArrayResource("b".getBytes())));
when(policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP))
.thenReturn(new PolicyRunHandle("r", new CompletableFuture<>()));
runner.runWith(policy, inputs, PolicyProgressListener.NOOP);
String key = EditorSource.counterKey(7L);
assertEquals(2, docCounter.statsFor(List.of(key)).get(key).total());
}
/** Persists each spec as a source and returns a policy referencing them by id. */
private Policy policy(List<InputSpec> sources) {
List<String> sourceIds =
@@ -110,6 +110,33 @@ class SourceControllerTest {
assertEquals(404, controller.delete("nope").getStatusCode().value());
}
@Test
void documentCountsForTheEditorReturnsTheTeamSeries() {
ResponseEntity<List<Long>> response = controller.documentCounts(EditorSource.ID);
assertEquals(200, response.getStatusCode().value());
assertEquals(30, response.getBody().size());
}
@Test
void theEditorIsBuiltInAndCannotBeDeleted() {
ResponseStatusException ex =
assertThrows(
ResponseStatusException.class, () -> controller.delete(EditorSource.ID));
assertEquals(400, ex.getStatusCode().value());
}
@Test
void theEditorIsBuiltInAndCannotBeSaved() {
Source editor = new Source(null, "Editor", "editor", Map.of(), true, null, null);
ResponseStatusException ex =
assertThrows(ResponseStatusException.class, () -> controller.save(editor));
assertEquals(400, ex.getStatusCode().value());
}
@Test
void readsReturnSecretsAsTheRedactionSentinel() {
Source saved = sourceStore.save(s3Source("shh"));
@@ -56,9 +56,10 @@ class SourceOverviewServiceTest {
SourcesResponse response = service.overview();
assertEquals(3, response.sources().size());
// Sorted most-referenced first, so the shared source A leads.
assertEquals(a.id(), response.sources().get(0).id());
assertEquals(4, response.sources().size());
// The built-in editor is pinned first; persisted sources follow, most-referenced leading.
assertEquals(EditorSource.ID, response.sources().get(0).id());
assertEquals(a.id(), response.sources().get(1).id());
SourceView av = find(response, a.id());
assertEquals(2, av.referenceCount());
@@ -123,13 +124,57 @@ class SourceOverviewServiceTest {
SourcesResponse response = scoped.overview();
assertEquals(1, response.sources().size());
SourceView view = response.sources().get(0);
assertEquals(ours.id(), view.id());
assertEquals(2, response.sources().size());
assertEquals(EditorSource.ID, response.sources().get(0).id());
SourceView view = find(response, ours.id());
assertEquals(1, view.referenceCount());
assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(SourceKpi::value).toList());
}
@Test
void theEditorSourceIsAlwaysPresentEvenWithNoConnections() {
SourcesResponse response = service.overview();
assertEquals(1, response.sources().size());
SourceView editor = response.sources().get(0);
assertEquals(EditorSource.ID, editor.id());
assertEquals("editor", editor.type());
assertEquals("active", editor.status());
assertEquals(0, editor.referenceCount());
// KPIs describe configured connections, so the built-in editor is left out of them.
assertEquals(List.of(0L, 0L, 0L), response.kpis().stream().map(SourceKpi::value).toList());
}
@Test
void theEditorSourceIsUsedByEveryPolicyThatRunsFromIt() {
editorPolicy("Redact on upload");
editorPolicy("Classify on upload");
// A folder-sourced policy does not target the editor, so it must not inflate the count.
policyReferencing("Folder sweep", source("Folder", "/f").id());
SourceView editor = find(service.overview(), EditorSource.ID);
assertEquals(2, editor.referenceCount());
assertTrue(
editor.referencingPolicies().stream()
.map(SourceView.PolicyRef::name)
.toList()
.containsAll(List.of("Redact on upload", "Classify on upload")));
}
@Test
void theEditorSourceReportsTheTeamsRecordedDocumentThroughput() {
// Login disabled, so the team is null and the editor shares the global counter bucket.
docCounter.record(EditorSource.counterKey(null), 4);
docCounter.record(EditorSource.counterKey(null), 6);
SourceView editor = find(service.overview(), EditorSource.ID);
assertEquals(10, editor.docsTotal());
assertEquals(10, editor.docs24h());
assertEquals(10, editor.docs30d());
}
@Test
void documentCountsReflectRecordedDocs() {
Source a = source("A", "/a");
@@ -177,6 +222,22 @@ class SourceOverviewServiceTest {
OutputSpec.inline()));
}
/**
* A policy that targets the editor: membership rides in its output metadata, not a sourceId.
*/
private void editorPolicy(String name) {
policyStore.save(
new Policy(
null,
name,
"owner",
true,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
new OutputSpec("inline", Map.of("sources", List.of("editor")))));
}
private void teamPolicy(String name, Long teamId, String... sourceIds) {
policyStore.save(
new Policy(
@@ -7966,7 +7966,9 @@ status = "Status"
usedBy = "Policies"
[portal.sources.types.editor]
description = "Documents your team has processed in the editor, across policy and AI runs."
label = "Editor"
noPolicies = "No policies run from the editor yet."
[portal.sources.types.folder]
description = "Watch a directory on the server for new documents."
@@ -2,7 +2,10 @@ import { useTranslation } from "react-i18next";
import { ActionIcon, Button } from "@app/ui";
import type { SourceView } from "@portal/api/sources";
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
import {
EDITOR_SOURCE_TYPE,
sourceTypeMeta,
} from "@portal/components/sources/sourceTypes";
import "@portal/views/Sources.css";
import CloseIcon from "@mui/icons-material/Close";
@@ -30,6 +33,8 @@ export function SourceDetailCard({
const { t } = useTranslation();
const meta = sourceTypeMeta(source.type);
const paused = source.status === "disabled";
// The editor is a built-in source: it has no instance name and can't be edited/paused/deleted.
const isEditor = source.type === EDITOR_SOURCE_TYPE;
return (
<section className="portal-sources__expanded">
<header className="portal-sources__expanded-head">
@@ -40,7 +45,9 @@ export function SourceDetailCard({
{meta.icon}
</span>
<div>
<h2 className="portal-sources__expanded-title">{source.name}</h2>
<h2 className="portal-sources__expanded-title">
{isEditor ? t(meta.labelKey) : source.name}
</h2>
<span className="portal-sources__expanded-sub">
{t("portal.sources.detail.subtitle", {
type: t(meta.labelKey),
@@ -60,32 +67,34 @@ export function SourceDetailCard({
<SourceDetailPanel source={source} docSeries={docSeries} />
<div className="portal-sources__detail-actions">
<Button
variant="secondary"
disabled={busy}
onClick={() => onEdit(source)}
>
{t("portal.sources.detail.edit")}
</Button>
<Button
variant="secondary"
disabled={busy}
onClick={() => onTogglePause(source)}
>
{paused
? t("portal.sources.detail.resume")
: t("portal.sources.detail.pause")}
</Button>
<Button
accent="danger"
variant="secondary"
disabled={busy}
onClick={() => onDelete(source)}
>
{t("portal.sources.detail.delete")}
</Button>
</div>
{!isEditor && (
<div className="portal-sources__detail-actions">
<Button
variant="secondary"
disabled={busy}
onClick={() => onEdit(source)}
>
{t("portal.sources.detail.edit")}
</Button>
<Button
variant="secondary"
disabled={busy}
onClick={() => onTogglePause(source)}
>
{paused
? t("portal.sources.detail.resume")
: t("portal.sources.detail.pause")}
</Button>
<Button
accent="danger"
variant="secondary"
disabled={busy}
onClick={() => onDelete(source)}
>
{t("portal.sources.detail.delete")}
</Button>
</div>
)}
</section>
);
}
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { Chip, StatTile } from "@app/ui";
import type { SourceView } from "@portal/api/sources";
import { Sparkline } from "@portal/components/sources/Sparkline";
import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes";
import "@portal/views/Sources.css";
interface SourceDetailPanelProps {
@@ -20,8 +21,15 @@ export function SourceDetailPanel({
docSeries,
}: SourceDetailPanelProps) {
const { t } = useTranslation();
const isEditor = source.type === EDITOR_SOURCE_TYPE;
return (
<div className="portal-sources__detail">
{isEditor && (
<p className="portal-sources__muted">
{t("portal.sources.types.editor.description")}
</p>
)}
{source.config.length > 0 && (
<div className="portal-sources__stat-grid">
{source.config.map((row) => (
@@ -36,7 +44,11 @@ export function SourceDetailPanel({
</span>
{source.referencingPolicies.length === 0 ? (
<p className="portal-sources__muted">
{t("portal.sources.detail.notReferenced")}
{t(
isEditor
? "portal.sources.types.editor.noPolicies"
: "portal.sources.detail.notReferenced",
)}
</p>
) : (
<div className="portal-sources__chips">
@@ -8,7 +8,10 @@ import {
type TableColumn,
} from "@app/ui";
import type { SourceStatus, SourceView } from "@portal/api/sources";
import { sourceTypeMeta } from "@portal/components/sources/sourceTypes";
import {
EDITOR_SOURCE_TYPE,
sourceTypeMeta,
} from "@portal/components/sources/sourceTypes";
import "@portal/views/Sources.css";
const STATUS_TONE: Record<SourceStatus, StatusTone> = {
@@ -37,6 +40,9 @@ export function SourcesTable({
header: t("portal.sources.table.source"),
render: (s) => {
const meta = sourceTypeMeta(s.type);
// The editor is a system source with no instance name: label it from its type and drop
// the chip, which would just repeat the name.
const isEditor = s.type === EDITOR_SOURCE_TYPE;
return (
<div className="portal-sources__name-cell">
<span
@@ -46,10 +52,12 @@ export function SourcesTable({
{meta.icon}
</span>
<div className="portal-sources__name-text">
<strong>{s.name}</strong>
<Chip accent={meta.accent} size="sm">
{t(meta.labelKey)}
</Chip>
<strong>{isEditor ? t(meta.labelKey) : s.name}</strong>
{!isEditor && (
<Chip accent={meta.accent} size="sm">
{t(meta.labelKey)}
</Chip>
)}
</div>
</div>
);
@@ -15,6 +15,13 @@ export interface SourceTypeMeta {
accent: ChipAccent;
}
/**
* The built-in editor source. It is virtual (never created/edited/deleted like a folder), always
* present in the list, and only tracks throughput - so rows of this type render without a config,
* a type chip, or edit/pause/delete actions.
*/
export const EDITOR_SOURCE_TYPE = "editor";
const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = {
folder: {
labelKey: "portal.sources.types.folder.label",
@@ -144,14 +144,37 @@ function buildKpis(views: SourceView[]): SourceKpi[] {
];
}
/** The built-in editor source's demo throughput, mirroring the backend's per-team counters. */
const editorDocs = { total: 8230, last24h: 42, last30d: 1680 };
/** The always-present editor row, pinned first and never editable/deletable. */
function editorView(): SourceView {
return {
id: "editor",
name: "Editor",
type: "editor",
status: "active",
referenceCount: 2,
referencingPolicies: [
{ id: "pol_security", name: "Security Policy" },
{ id: "pol_redaction", name: "Redaction Policy" },
],
config: [],
docsTotal: editorDocs.total,
docs24h: editorDocs.last24h,
docs30d: editorDocs.last30d,
};
}
function buildOverview(): SourcesResponse {
const views = store
const persisted = store
.map((s) => toSourceView(s, refsFor(s.id)))
.sort(
(a, b) =>
b.referenceCount - a.referenceCount || a.name.localeCompare(b.name),
);
return { kpis: buildKpis(views), sources: views };
// KPIs describe the configured connections; the built-in editor is pinned first but not counted.
return { kpis: buildKpis(persisted), sources: [editorView(), ...persisted] };
}
export const sourcesHandlers = [
@@ -169,6 +192,9 @@ export const sourcesHandlers = [
http.get("/api/v1/sources/:id/document-counts", async ({ params }) => {
await delay(120);
if (params.id === "editor") {
return HttpResponse.json(sampleDailySeries(editorDocs.last30d / 30));
}
const source = store.find((s) => s.id === params.id);
if (!source) return new HttpResponse(null, { status: 404 });
return HttpResponse.json(docsFor(source.id).daily);
@@ -195,6 +221,12 @@ export const sourcesHandlers = [
http.delete("/api/v1/sources/:id", async ({ params }) => {
await delay(120);
const id = String(params.id);
if (id === "editor") {
return HttpResponse.json(
{ detail: "The editor is a built-in source and cannot be deleted" },
{ status: 400 },
);
}
const source = store.find((s) => s.id === id);
if (!source) return new HttpResponse(null, { status: 404 });
const refs = refsFor(id);
@@ -51,6 +51,7 @@ import {
import { clearProcessedHistory } from "@portal/api/policies";
import { availableOutputModes } from "@portal/components/pipelines/outputModes";
import { fetchSources, type SourceView } from "@portal/api/sources";
import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes";
import { useAsync } from "@portal/hooks/useAsync";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations";
@@ -172,7 +173,12 @@ export function PipelineBuilder() {
[id],
);
const sourcesState = useAsync<SourceView[]>(
async () => (await fetchSources()).sources,
// The editor is a built-in, client-driven source (it runs on editor upload, not as a pipeline
// input), so it's excluded from the sources a pipeline can pull from.
async () =>
(await fetchSources()).sources.filter(
(source) => source.type !== EDITOR_SOURCE_TYPE,
),
[],
);
const triggersState = useAsync<TriggerInfo[]>(
@@ -122,6 +122,39 @@ describe("Sources view", () => {
).toBeInTheDocument();
});
it("shows the editor as a built-in source with no edit, pause, or delete actions", async () => {
fetchSources.mockResolvedValue({
kpis: [],
sources: [
{
id: "editor",
name: "Editor",
type: "editor",
status: "active",
referenceCount: 1,
referencingPolicies: [{ id: "pol-1", name: "Redaction" }],
config: [],
docsTotal: 8230,
docs24h: 42,
docs30d: 1680,
},
],
} satisfies SourcesResponse);
renderView();
// The editor row is labelled from its type (i18n keys are returned verbatim here).
fireEvent.click(
await screen.findByText("portal.sources.types.editor.label"),
);
// Detail opens, but none of the mutate actions are offered for the built-in source.
await screen.findByText("portal.sources.detail.documents");
expect(screen.queryByText("portal.sources.detail.edit")).toBeNull();
expect(screen.queryByText("portal.sources.detail.pause")).toBeNull();
expect(screen.queryByText("portal.sources.detail.delete")).toBeNull();
});
it("pauses a source by re-saving it with enabled flipped off", async () => {
fetchSources.mockResolvedValue(RESPONSE);
fetchSource.mockResolvedValue({