Skip to content
Merged
57 changes: 57 additions & 0 deletions docs/public/user-guide/pipelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,63 @@ Each metric can only have one SLI per pipeline. Adding an SLI for a metric that
If no metric data is available for the evaluation window (for example, the pipeline was recently deployed or has no traffic), the SLI is treated as **breached** and the pipeline health will show as **Degraded**.
{% endhint %}

## Data classification tags

Classification tags let you label pipelines with compliance or sensitivity markers such as **PII**, **PHI**, **PCI-DSS**, **Internal**, or **Public**. Tags appear as color-coded badges next to pipeline names in the list and help teams quickly identify which data-handling rules apply to each pipeline.

### Defining available tags (Admin)

Tags are defined at the **team** level. Only team admins can manage the list of available tags.

{% stepper %}
{% step %}
### Open team settings
Navigate to **Settings** and select the **Team** tab.
{% endstep %}
{% step %}
### Add tags
Scroll to the **Data Classification Tags** card. Type a tag name (up to 30 characters) and click **Add**. Repeat for each tag you want to make available.
{% endstep %}
{% step %}
### Remove tags
Click the **X** button on any existing tag to remove it from the available list. Removing a tag from the team does not automatically remove it from pipelines that already have it applied.
{% endstep %}
{% endstepper %}

### Applying tags to pipelines

{% stepper %}
{% step %}
### Open pipeline settings
Open a pipeline in the editor, then click the **gear icon** in the toolbar to open Pipeline Settings.
{% endstep %}
{% step %}
### Select tags
In the **Classification Tags** section, use the dropdown to add tags from the team's available list. Tags are saved immediately.
{% endstep %}
{% step %}
### Remove tags
Click the **X** on any applied tag badge to remove it from the pipeline.
{% endstep %}
{% endstepper %}

### Tag color coding

Tags are color-coded in the pipeline list for quick visual identification:

| Tag | Color |
|-----|-------|
| PII | Red |
| PHI | Orange |
| PCI-DSS | Purple |
| Internal | Blue |
| Public | Green |
| Other | Gray |

{% hint style="info" %}
Tags are metadata labels only -- they do not enforce any access controls or data-handling policies. Use them as a visual aid for compliance awareness and pipeline organization.
{% endhint %}

## Filtering by environment

Pipelines are scoped to the currently selected **environment** (shown in the sidebar). Switch environments to view pipelines in a different environment. Each environment maintains its own independent set of pipelines, agent nodes, and secrets.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Pipeline" ADD COLUMN "tags" JSONB DEFAULT '[]';

-- AlterTable
ALTER TABLE "Team" ADD COLUMN "availableTags" JSONB DEFAULT '[]';
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ model Team {
templates Template[]
vrlSnippets VrlSnippet[]
alertRules AlertRule[]
availableTags Json? @default("[]") // string[] of admin-defined classification tags
createdAt DateTime @default(now())
}

Expand Down Expand Up @@ -217,6 +218,7 @@ model Pipeline {
sampleRequests EventSampleRequest[]
eventSamples EventSample[]
slis PipelineSli[]
tags Json? @default("[]") // string[] of classification tags like ["PII", "PCI-DSS"]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Expand Down
78 changes: 40 additions & 38 deletions src/app/(dashboard)/pipelines/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,16 @@ function reductionColor(pct: number): string {
return "bg-muted text-muted-foreground";
}

/** Lazily fetches SLI health for a single deployed pipeline. */
function tagBadgeClass(tag: string): string {
const upper = tag.toUpperCase();
if (upper === "PII") return "bg-red-500/15 text-red-700 dark:text-red-400 border-red-500/30";
if (upper === "PHI") return "bg-orange-500/15 text-orange-700 dark:text-orange-400 border-orange-500/30";
if (upper === "PCI-DSS") return "bg-purple-500/15 text-purple-700 dark:text-purple-400 border-purple-500/30";
if (upper === "INTERNAL") return "bg-blue-500/15 text-blue-700 dark:text-blue-400 border-blue-500/30";
if (upper === "PUBLIC") return "bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30";
return "bg-muted text-muted-foreground";
}

function PipelineHealthBadge({ pipelineId }: { pipelineId: string }) {
const trpc = useTRPC();
const healthQuery = useQuery(
Expand All @@ -89,39 +98,21 @@ function PipelineHealthBadge({ pipelineId }: { pipelineId: string }) {
{ refetchInterval: 30_000 },
),
);

const status = healthQuery.data?.status ?? null;
const hasSlis = (healthQuery.data?.slis.length ?? 0) > 0;

if (healthQuery.isLoading) {
return <Skeleton className="h-5 w-14" />;
}

if (status === "healthy") {
return (
<Badge variant="outline" className="bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/30">
Healthy
</Badge>
);
}
if (status === "degraded") {
return (
<Badge variant="outline" className="bg-yellow-500/15 text-yellow-700 dark:text-yellow-400 border-yellow-500/30">
Degraded
</Badge>
);
}
if (status === "no_data" && hasSlis) {
return (
<Badge variant="outline" className="text-muted-foreground">
No Data
</Badge>
);
}
if (!status || status === "no_data") return null;
return (
<Badge variant="outline" className="text-muted-foreground">
No SLIs
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<span
className={`inline-block h-2 w-2 rounded-full ${
status === "healthy" ? "bg-green-500" : "bg-yellow-500"
}`}
/>
</TooltipTrigger>
<TooltipContent>
{status === "healthy" ? "All SLIs met" : "One or more SLIs breached"}
</TooltipContent>
</Tooltip>
);
}

Expand Down Expand Up @@ -238,12 +229,23 @@ export default function PipelinesPage() {
return (
<TableRow key={pipeline.id} className="cursor-pointer hover:bg-muted/50">
<TableCell className="font-medium">
<Link
href={`/pipelines/${pipeline.id}`}
className="hover:underline"
>
{pipeline.name}
</Link>
<div className="flex items-center gap-2">
<Link
href={`/pipelines/${pipeline.id}`}
className="hover:underline"
>
{pipeline.name}
</Link>
{(pipeline.tags as string[])?.length > 0 && (
<div className="flex items-center gap-1">
{(pipeline.tags as string[]).map((tag) => (
<Badge key={tag} variant="outline" className={`text-[10px] px-1.5 py-0 ${tagBadgeClass(tag)}`}>
{tag}
</Badge>
))}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
Expand Down
116 changes: 116 additions & 0 deletions src/app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,7 @@ function TeamSettings() {
const [inviteRole, setInviteRole] = useState<"VIEWER" | "EDITOR" | "ADMIN">(
"VIEWER"
);
const [newTag, setNewTag] = useState("");

const updateRoleMutation = useMutation(
trpc.team.updateMemberRole.mutationOptions({
Expand Down Expand Up @@ -1159,6 +1160,66 @@ function TeamSettings() {
})
);

// Data classification tags
const availableTagsQuery = useQuery(
trpc.team.getAvailableTags.queryOptions(
{ teamId: selectedTeamId! },
{ enabled: !!selectedTeamId },
),
);
const availableTags = availableTagsQuery.data ?? [];
const tagsQueryKey = trpc.team.getAvailableTags.queryKey({ teamId: selectedTeamId! });

const updateTagsMutation = useMutation(
trpc.team.updateAvailableTags.mutationOptions({
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: tagsQueryKey });
const previous = queryClient.getQueryData(tagsQueryKey);
const previousInput = newTag;
queryClient.setQueryData(tagsQueryKey, variables.tags);
setNewTag("");
return { previous, previousInput };
},
onError: (error, _variables, context) => {
if (context?.previous !== undefined) {
queryClient.setQueryData(tagsQueryKey, context.previous);
}
if (context?.previousInput !== undefined) {
setNewTag(context.previousInput);
}
toast.error(error.message || "Failed to update tags");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tagsQueryKey });
},
onSuccess: () => {
toast.success("Tags updated");
},
}),
);

const handleAddTag = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = newTag.trim();
if (!selectedTeamId || !trimmed) return;
if (availableTags.includes(trimmed)) {
toast.error("Tag already exists");
return;
}
updateTagsMutation.mutate({
teamId: selectedTeamId,
tags: [...availableTags, trimmed],
});
};

const handleRemoveTag = (tag: string) => {
if (!selectedTeamId) return;
updateTagsMutation.mutate({
teamId: selectedTeamId,
tags: availableTags.filter((t) => t !== tag),
});
};

const handleRename = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedTeamId || !teamName.trim()) return;
Expand Down Expand Up @@ -1634,6 +1695,61 @@ function TeamSettings() {
</form>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Data Classification Tags</CardTitle>
<CardDescription>
Define classification tags that can be applied to pipelines in this team (e.g., PII, PHI, PCI-DSS).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{availableTags.length === 0 && (
<span className="text-sm text-muted-foreground">No tags defined yet.</span>
)}
{availableTags.map((tag) => (
<Badge key={tag} variant="outline" className="text-sm gap-1.5">
{tag}
<button
type="button"
className="inline-flex items-center rounded-full hover:bg-black/10 dark:hover:bg-white/10"
onClick={() => handleRemoveTag(tag)}
disabled={updateTagsMutation.isPending}
aria-label={`Remove ${tag} tag`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<form onSubmit={handleAddTag} className="flex items-end gap-3">
<div className="flex-1 space-y-2">
<Label htmlFor="new-tag">Add Tag</Label>
<Input
id="new-tag"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder="e.g., PII, Internal, PCI-DSS"
maxLength={30}
/>
</div>
<Button type="submit" className="h-9" disabled={updateTagsMutation.isPending || !newTag.trim()}>
{updateTagsMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Adding...
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Add
</>
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/flow/detail-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ export function DetailPanel({ pipelineId, isDeployed }: DetailPanelProps) {
<TabsContent value="live-tail" className="min-h-0 flex-1 overflow-y-auto">
<LiveTailPanel
pipelineId={pipelineId}
componentKey={componentKey}
componentKey={storeKey}
isDeployed={isDeployed}
/>
</TabsContent>
Expand Down
Loading
Loading