preview Impact

This commit is contained in:
Ishaan Jaffer 2026-02-10 16:34:23 -08:00
parent 710e132201
commit 850f71135a
5 changed files with 275 additions and 44 deletions

View file

@ -1,10 +1,12 @@
import React, { useState, useEffect } from "react";
import { Modal, Form, Select, Radio, Divider, Typography } from "antd";
import { Button } from "@tremor/react";
import { Policy, PolicyAttachmentCreateRequest } from "./types";
import { teamListCall, keyInfoCall, modelAvailableCall } from "../networking";
import { Policy } from "./types";
import { teamListCall, keyListCall, modelAvailableCall, estimateAttachmentImpactCall } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { buildAttachmentData } from "./build_attachment_data";
import ImpactPreviewAlert from "./impact_preview_alert";
const { Text } = Typography;
@ -34,6 +36,8 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
const [isLoadingTeams, setIsLoadingTeams] = useState(false);
const [isLoadingKeys, setIsLoadingKeys] = useState(false);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [isEstimating, setIsEstimating] = useState(false);
const [impactResult, setImpactResult] = useState<any>(null);
const { userId, userRole } = useAuthorized();
useEffect(() => {
@ -46,33 +50,30 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
const loadTeamsKeysAndModels = async () => {
if (!accessToken) return;
// Load teams
// Load teams — teamListCall returns a plain array of team objects
setIsLoadingTeams(true);
try {
// Pass null for organizationID since we're loading all teams the user has access to
const teamsResponse = await teamListCall(accessToken, null, userId);
if (teamsResponse?.data) {
const teamAliases = teamsResponse.data
.map((t: any) => t.team_alias)
.filter(Boolean);
setAvailableTeams(teamAliases);
}
const teamsArray = Array.isArray(teamsResponse) ? teamsResponse : (teamsResponse?.data || []);
const teamAliases = teamsArray
.map((t: any) => t.team_alias)
.filter(Boolean);
setAvailableTeams(teamAliases);
} catch (error) {
console.error("Failed to load teams:", error);
} finally {
setIsLoadingTeams(false);
}
// Load keys
// Load keys — keyListCall returns {keys: [...], total_count, ...}
setIsLoadingKeys(true);
try {
const keysResponse = await keyInfoCall(accessToken, []);
if (keysResponse?.data) {
const keyAliases = keysResponse.data
.map((k: any) => k.key_alias)
.filter(Boolean);
setAvailableKeys(keyAliases);
}
const keysResponse = await keyListCall(accessToken, null, null, null, null, null, 1, 100);
const keysArray = keysResponse?.keys || keysResponse?.data || [];
const keyAliases = keysArray
.map((k: any) => k.key_alias)
.filter(Boolean);
setAvailableKeys(keyAliases);
} catch (error) {
console.error("Failed to load keys:", error);
} finally {
@ -83,12 +84,11 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
setIsLoadingModels(true);
try {
const modelsResponse = await modelAvailableCall(accessToken, userId || "", userRole || "");
if (modelsResponse?.data) {
const modelIds = modelsResponse.data
.map((m: any) => m.id || m.model_name)
.filter(Boolean);
setAvailableModels(modelIds);
}
const modelsArray = modelsResponse?.data || (Array.isArray(modelsResponse) ? modelsResponse : []);
const modelIds = modelsArray
.map((m: any) => m.id || m.model_name)
.filter(Boolean);
setAvailableModels(modelIds);
} catch (error) {
console.error("Failed to load models:", error);
} finally {
@ -99,6 +99,28 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
const resetForm = () => {
form.resetFields();
setScopeType("global");
setImpactResult(null);
};
const getAttachmentData = () => buildAttachmentData(form.getFieldsValue(true), scopeType);
const handlePreviewImpact = async () => {
if (!accessToken) return;
try {
await form.validateFields(["policy_name"]);
} catch {
return;
}
setIsEstimating(true);
try {
const data = getAttachmentData();
const result = await estimateAttachmentImpactCall(accessToken, data);
setImpactResult(result);
} catch (error) {
console.error("Failed to estimate impact:", error);
} finally {
setIsEstimating(false);
}
};
const handleClose = () => {
@ -110,30 +132,12 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
try {
setIsSubmitting(true);
await form.validateFields();
const values = form.getFieldsValue(true);
if (!accessToken) {
throw new Error("No access token available");
}
const data: PolicyAttachmentCreateRequest = {
policy_name: values.policy_name,
};
if (scopeType === "global") {
data.scope = "*";
} else {
if (values.teams && values.teams.length > 0) {
data.teams = values.teams;
}
if (values.keys && values.keys.length > 0) {
data.keys = values.keys;
}
if (values.models && values.models.length > 0) {
data.models = values.models;
}
}
const data = getAttachmentData();
await createAttachment(accessToken, data);
NotificationsManager.success("Attachment created successfully");
@ -195,8 +199,8 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
value={scopeType}
onChange={(e) => setScopeType(e.target.value)}
>
<Radio value="specific">Specific (teams, keys, models, or tags)</Radio>
<Radio value="global">Global (applies to all requests)</Radio>
<Radio value="specific">Specific (teams, keys, or models)</Radio>
</Radio.Group>
</Form.Item>
@ -267,13 +271,41 @@ const AddAttachmentForm: React.FC<AddAttachmentFormProps> = ({
style={{ width: "100%" }}
/>
</Form.Item>
<Form.Item
name="tags"
label="Tags"
tooltip="Match against tags set in key or team metadata. Use exact values (e.g., healthcare) or wildcard patterns (e.g., health-*) where * matches any suffix."
extra={
<Text type="secondary" style={{ fontSize: 12 }}>
Matches tags from key/team <code>metadata.tags</code> or tags passed dynamically in the request body. Use <code>*</code> as a suffix wildcard (e.g., <code>prod-*</code> matches <code>prod-us</code>, <code>prod-eu</code>).
</Text>
}
>
<Select
mode="tags"
placeholder="Type a tag and press Enter (e.g. healthcare, prod-*)"
tokenSeparators={[",", " "]}
notFoundContent={null}
suffixIcon={null}
open={false}
style={{ width: "100%" }}
/>
</Form.Item>
</>
)}
{impactResult && <ImpactPreviewAlert impactResult={impactResult} />}
<div className="flex justify-end space-x-2 mt-4">
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
{scopeType === "specific" && (
<Button variant="secondary" onClick={handlePreviewImpact} loading={isEstimating}>
Estimate Impact
</Button>
)}
<Button onClick={handleSubmit} loading={isSubmitting}>
Create Attachment
</Button>

View file

@ -11,12 +11,14 @@ import {
useReactTable,
} from "@tanstack/react-table";
import { PolicyAttachment } from "./types";
import ImpactPopover from "./impact_popover";
interface AttachmentTableProps {
attachments: PolicyAttachment[];
isLoading: boolean;
onDeleteClick: (attachmentId: string) => void;
isAdmin: boolean;
accessToken: string | null;
}
const AttachmentTable: React.FC<AttachmentTableProps> = ({
@ -24,6 +26,7 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
isLoading,
onDeleteClick,
isAdmin,
accessToken,
}) => {
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
@ -152,6 +155,31 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
);
},
},
{
header: "Tags",
accessorKey: "tags",
cell: ({ row }) => {
const attachment = row.original;
const tags = attachment.tags || [];
if (tags.length === 0) {
return <span className="text-xs text-gray-400">-</span>;
}
return (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 2).map((t, i) => (
<Tag key={i} color="orange" className="text-xs">
{t}
</Tag>
))}
{tags.length > 2 && (
<Tooltip title={tags.slice(2).join(", ")}>
<Tag className="text-xs">+{tags.length - 2}</Tag>
</Tooltip>
)}
</div>
);
},
},
{
header: "Created At",
accessorKey: "created_at",
@ -171,6 +199,7 @@ const AttachmentTable: React.FC<AttachmentTableProps> = ({
const attachment = row.original;
return (
<div className="flex space-x-2">
<ImpactPopover attachment={attachment} accessToken={accessToken} />
{isAdmin && (
<Tooltip title="Delete attachment">
<Icon

View file

@ -0,0 +1,25 @@
import { PolicyAttachmentCreateRequest } from "./types";
/**
* Builds a PolicyAttachmentCreateRequest from form values.
*
* @param formValues - The raw form field values (from form.getFieldsValue)
* @param scopeType - Whether the attachment is "global" or "specific"
*/
export function buildAttachmentData(
formValues: Record<string, any>,
scopeType: "global" | "specific"
): PolicyAttachmentCreateRequest {
const data: PolicyAttachmentCreateRequest = {
policy_name: formValues.policy_name,
};
if (scopeType === "global") {
data.scope = "*";
} else {
if (formValues.teams && formValues.teams.length > 0) data.teams = formValues.teams;
if (formValues.keys && formValues.keys.length > 0) data.keys = formValues.keys;
if (formValues.models && formValues.models.length > 0) data.models = formValues.models;
if (formValues.tags && formValues.tags.length > 0) data.tags = formValues.tags;
}
return data;
}

View file

@ -0,0 +1,84 @@
import React, { useState } from "react";
import { Icon } from "@tremor/react";
import { EyeIcon } from "@heroicons/react/outline";
import { Tooltip, Tag, Popover, Spin } from "antd";
import { PolicyAttachment } from "./types";
import { estimateAttachmentImpactCall } from "../networking";
const ImpactPopover: React.FC<{ attachment: PolicyAttachment; accessToken: string | null }> = ({
attachment,
accessToken,
}) => {
const [impact, setImpact] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const loadImpact = async () => {
if (loaded || loading || !accessToken) return;
setLoading(true);
try {
const data = await estimateAttachmentImpactCall(accessToken, {
policy_name: attachment.policy_name,
scope: attachment.scope,
teams: attachment.teams,
keys: attachment.keys,
models: attachment.models,
tags: attachment.tags,
});
setImpact(data);
setLoaded(true);
} catch (error) {
console.error("Failed to load impact:", error);
} finally {
setLoading(false);
}
};
const content = loading ? (
<div className="p-2 text-center"><Spin size="small" /> Loading...</div>
) : impact ? (
<div className="text-xs" style={{ maxWidth: 280 }}>
{impact.affected_keys_count === -1 ? (
<p className="font-medium text-amber-600">Global scope — affects all keys and teams</p>
) : (
<>
<p className="mb-1">
<strong>{impact.affected_keys_count}</strong> key{impact.affected_keys_count !== 1 ? "s" : ""},{" "}
<strong>{impact.affected_teams_count}</strong> team{impact.affected_teams_count !== 1 ? "s" : ""} affected
</p>
{impact.sample_keys.length > 0 && (
<div className="mb-1">
<span className="text-gray-500">Keys: </span>
{impact.sample_keys.map((k: string) => (
<Tag key={k} style={{ fontSize: 10, margin: 1 }}>{k}</Tag>
))}
</div>
)}
{impact.sample_teams.length > 0 && (
<div>
<span className="text-gray-500">Teams: </span>
{impact.sample_teams.map((t: string) => (
<Tag key={t} style={{ fontSize: 10, margin: 1 }}>{t}</Tag>
))}
</div>
)}
{impact.affected_keys_count === 0 && impact.affected_teams_count === 0 && (
<p className="text-gray-400">No keys or teams currently affected</p>
)}
</>
)}
</div>
) : (
<p className="text-xs text-gray-400">Click to load</p>
);
return (
<Popover content={content} title="Blast Radius" trigger="click" onOpenChange={(open) => { if (open) loadImpact(); }}>
<Tooltip title="View blast radius">
<Icon icon={EyeIcon} size="sm" className="cursor-pointer hover:text-blue-500" />
</Tooltip>
</Popover>
);
};
export default ImpactPopover;

View file

@ -0,0 +1,61 @@
import React from "react";
import { Alert, Tag, Typography } from "antd";
const { Text } = Typography;
interface ImpactResult {
affected_keys_count: number;
affected_teams_count: number;
sample_keys: string[];
sample_teams: string[];
}
interface ImpactPreviewAlertProps {
impactResult: ImpactResult;
}
const ImpactPreviewAlert: React.FC<ImpactPreviewAlertProps> = ({ impactResult }) => {
return (
<Alert
type={impactResult.affected_keys_count === -1 ? "warning" : "info"}
showIcon
className="mb-4"
message="Impact Preview"
description={
impactResult.affected_keys_count === -1 ? (
<Text>Global scope — this will affect <strong>all keys and teams</strong>.</Text>
) : (
<div>
<Text>
This attachment would affect <strong>{impactResult.affected_keys_count} key{impactResult.affected_keys_count !== 1 ? "s" : ""}</strong> and <strong>{impactResult.affected_teams_count} team{impactResult.affected_teams_count !== 1 ? "s" : ""}</strong>.
</Text>
{impactResult.sample_keys.length > 0 && (
<div className="mt-1">
<Text type="secondary" style={{ fontSize: 12 }}>Keys: </Text>
{impactResult.sample_keys.slice(0, 5).map((k: string) => (
<Tag key={k} style={{ fontSize: 11 }}>{k}</Tag>
))}
{impactResult.affected_keys_count > 5 && (
<Text type="secondary" style={{ fontSize: 11 }}>and {impactResult.affected_keys_count - 5} more...</Text>
)}
</div>
)}
{impactResult.sample_teams.length > 0 && (
<div className="mt-1">
<Text type="secondary" style={{ fontSize: 12 }}>Teams: </Text>
{impactResult.sample_teams.slice(0, 5).map((t: string) => (
<Tag key={t} style={{ fontSize: 11 }}>{t}</Tag>
))}
{impactResult.affected_teams_count > 5 && (
<Text type="secondary" style={{ fontSize: 11 }}>and {impactResult.affected_teams_count - 5} more...</Text>
)}
</div>
)}
</div>
)
}
/>
);
};
export default ImpactPreviewAlert;