mirror of
https://github.com/FunnyWolf/agentic-soc-platform.git
synced 2026-08-22 13:12:56 +02:00
fix(cases): refine relationship experience
Make Artifact suggestions explicit and bounded, align Related Cases with shared table layouts, and update the marketplace skills contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+1
-1
Submodule asp-marketplace updated: 284f3161e5...41982af7da
@@ -1,12 +1,24 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models import Count, Q, Subquery
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from django.db import OperationalError, connection, transaction
|
||||
from django.db.models import Count, Max, Q, Subquery
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import APIException, ValidationError
|
||||
|
||||
from apps.alerts.models import Alert
|
||||
from apps.artifacts.models import Artifact
|
||||
|
||||
from .models import Case, CaseRelationship, CaseRelationshipType
|
||||
|
||||
SUGGESTION_ARTIFACT_LIMIT = 20
|
||||
SUGGESTION_QUERY_TIMEOUT_MS = 3000
|
||||
|
||||
|
||||
class SuggestionQueryTimeout(APIException):
|
||||
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
default_detail = "Case relationship suggestions timed out because the dataset is too large."
|
||||
default_code = "suggestion_query_timeout"
|
||||
|
||||
|
||||
def case_summary(case):
|
||||
return {
|
||||
@@ -142,13 +154,14 @@ def validate_relationship(source_case, target_case, relationship_type, relations
|
||||
_validate_duplicate_relationship(source_case, target_case, relationship_id)
|
||||
|
||||
|
||||
def suggest_related_cases(case, limit=10):
|
||||
def _suggest_related_cases(case, limit):
|
||||
source_artifact_ids = (
|
||||
Artifact.objects
|
||||
.filter(alerts__case=case)
|
||||
.order_by()
|
||||
.values("id")
|
||||
.distinct()
|
||||
Alert.artifacts.through.objects
|
||||
.filter(alert__case=case)
|
||||
.values("artifact_id")
|
||||
.annotate(last_link_id=Max("id"))
|
||||
.order_by("-last_link_id")
|
||||
.values("artifact_id")[:SUGGESTION_ARTIFACT_LIMIT]
|
||||
)
|
||||
related_case_ids = set()
|
||||
for source_case_id, target_case_id in (
|
||||
@@ -202,3 +215,22 @@ def suggest_related_cases(case, limit=10):
|
||||
}
|
||||
for candidate in candidates
|
||||
]
|
||||
|
||||
|
||||
def suggest_related_cases(case, limit=10):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT set_config('statement_timeout', %s, true)",
|
||||
[f"{SUGGESTION_QUERY_TIMEOUT_MS}ms"],
|
||||
)
|
||||
return _suggest_related_cases(case, limit)
|
||||
except OperationalError as exc:
|
||||
cause = exc.__cause__
|
||||
if (
|
||||
getattr(cause, "sqlstate", None) == "57014"
|
||||
or getattr(cause, "pgcode", None) == "57014"
|
||||
):
|
||||
raise SuggestionQueryTimeout() from exc
|
||||
raise
|
||||
|
||||
@@ -200,8 +200,16 @@ class CaseRelationshipViewSet(AuditActorMixin, viewsets.ModelViewSet):
|
||||
).order_by("-created_at")
|
||||
serializer_class = CaseRelationshipSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsBusinessWriterOrReadOnly]
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter)
|
||||
filterset_fields = ("relationship_type",)
|
||||
search_fields = (
|
||||
"source_case__case_id",
|
||||
"source_case__title",
|
||||
"target_case__case_id",
|
||||
"target_case__title",
|
||||
"note",
|
||||
"created_by__username",
|
||||
)
|
||||
ordering_fields = ("relationship_type", "created_at", "updated_at")
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -124,6 +124,7 @@ DELETE /api/case-relationships/{id}/
|
||||
|
||||
- `case=<uuid>`:返回该 Case 的入边和出边。
|
||||
- `relationship_type=<value>`。
|
||||
- Case readable ID/title、note 和 creator 搜索。
|
||||
- created_at/updated_at/relationship_type ordering。
|
||||
- 标准分页。
|
||||
|
||||
@@ -164,14 +165,16 @@ GET /api/case-relationships/suggestions/?case=<uuid>
|
||||
|
||||
规则:
|
||||
|
||||
1. 取当前 Case 的 Alert 已关联 Artifact 记录。
|
||||
2. 查找共享至少一个相同 Artifact 记录的其他 Case。
|
||||
3. 排除当前 Case 和已经存在正式关系的 Case。
|
||||
4. 按共享 Artifact 去重数量降序。
|
||||
5. 相同数量按 Case updated_at 降序。
|
||||
6. 最多返回 10 个候选。
|
||||
7. 每项返回共享数量和最多 3 个 Artifact 摘要(id/type/value)。
|
||||
8. 不应用时间窗口、类型权重、文本相似度或 LLM 判断。
|
||||
1. 用户或 Agent 显式请求时才执行,不随 Related Cases Tab 加载或关系变更自动执行。
|
||||
2. 按最近关联顺序最多取当前 Case 的 20 个去重 Artifact 记录。
|
||||
3. 查找共享至少一个相同 Artifact 记录的其他 Case。
|
||||
4. 排除当前 Case 和已经存在正式关系的 Case。
|
||||
5. 按共享 Artifact 去重数量降序。
|
||||
6. 相同数量按 Case updated_at 降序。
|
||||
7. 最多返回 10 个候选。
|
||||
8. 每项返回共享数量和最多 3 个 Artifact 摘要(id/type/value)。
|
||||
9. 数据库查询超时为 3 秒;超时返回 503,不继续占用数据库连接执行。
|
||||
10. 不应用时间窗口、类型权重、文本相似度或 LLM 判断。
|
||||
|
||||
候选按请求动态计算,不持久化、不写 AuditLog、不需要 Worker。
|
||||
|
||||
@@ -229,7 +232,8 @@ Case Relationship 不参与:
|
||||
|
||||
新增 Related Cases Tab:
|
||||
|
||||
- 正式关系列表。
|
||||
- 使用与其他关联 Tab 一致的全高度 DataTable 展示正式关系列表。
|
||||
- 工具栏使用图标按钮添加关系和查找候选。
|
||||
- 相对当前 Case 的关系语义。
|
||||
- 对端 Case readable ID/title/status/severity/verdict。
|
||||
- note、creator、created time。
|
||||
@@ -247,9 +251,13 @@ Add/Edit:
|
||||
|
||||
Suggestions:
|
||||
|
||||
- 默认不查询;用户点击工具栏查找图标后打开弹窗并发起请求。
|
||||
- 查询期间按钮不可重复触发。
|
||||
- 候选在弹窗内使用紧凑表格展示,错误在弹窗内提示并支持 Retry。
|
||||
- 展示共享 Artifact 数量。
|
||||
- 展示最多 3 个 type/value,剩余显示 +N。
|
||||
- Admin/User 可确认 Add as Related。
|
||||
- 确认后保持弹窗打开、移除已处理候选并刷新正式关系表。
|
||||
- Viewer 只读。
|
||||
|
||||
### Case list
|
||||
@@ -278,7 +286,7 @@ Suggestions:
|
||||
8. 关系不改变 Case 或任何关联业务对象。
|
||||
9. Case list/detail relationship_count 正确,列表列默认隐藏。
|
||||
10. Related Cases Tab 从任一端显示正确反向语义并可打开对端。
|
||||
11. Artifact suggestions 排除自身和正式关联,按共享数量返回 Top 10。
|
||||
11. Artifact suggestions 仅手动触发,最多使用 20 个 Artifact,排除自身和正式关联并按共享数量返回 Top 10。
|
||||
12. 每项 suggestion 返回数量和最多 3 个 Artifact 证据。
|
||||
13. 确认 suggestion 只创建 Related。
|
||||
14. Agent 默认只读取直接正式关系,suggestions 需主动查询。
|
||||
@@ -289,6 +297,7 @@ Suggestions:
|
||||
|
||||
- 共享 Artifact 必须是同一个数据库记录;同值但不同 name/role 的 Artifact 可能无法互相建议。
|
||||
- 常见 Artifact 可能产生弱相关候选,因此必须人工确认。
|
||||
- 候选查询是有界的低频辅助功能;超过 3 秒会终止并提示数据量过大。
|
||||
- 不做递归图展示,跨多跳关系需要逐个打开 Case。
|
||||
- 不支持自定义关系类型。
|
||||
- Case 删除会删除当前关系边,历史只通过 AuditLog 追溯。
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface RelatedCaseSummary {
|
||||
}
|
||||
|
||||
export interface CaseRelationship {
|
||||
[key: string]: unknown
|
||||
id: string
|
||||
source_case: RelatedCaseSummary
|
||||
target_case: RelatedCaseSummary
|
||||
@@ -41,27 +42,6 @@ export interface CaseRelationshipInput {
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface CaseRelationshipPage {
|
||||
count: number
|
||||
results: CaseRelationship[]
|
||||
}
|
||||
|
||||
function results<T>(data: T[] | {results?: T[]}) {
|
||||
return Array.isArray(data) ? data : data.results || []
|
||||
}
|
||||
|
||||
export async function fetchCaseRelationships(caseId: string, page = 1) {
|
||||
const {data} = await client.get<CaseRelationship[] | {count?: number; results?: CaseRelationship[]}>(
|
||||
'/case-relationships/',
|
||||
{params: {case: caseId, page, page_size: 20}},
|
||||
)
|
||||
const pageResults = results(data)
|
||||
return {
|
||||
count: Array.isArray(data) ? data.length : data.count || pageResults.length,
|
||||
results: pageResults,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCaseRelationshipSuggestions(caseId: string) {
|
||||
const {data} = await client.get<{results?: CaseRelationshipSuggestion[]}>(
|
||||
'/case-relationships/suggestions/',
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
List,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
theme,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import {DeleteOutlined, EditOutlined, PlusOutlined} from '@ant-design/icons'
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type {ColumnsType} from 'antd/es/table'
|
||||
import {
|
||||
type CaseRelationship,
|
||||
type CaseRelationshipInput,
|
||||
@@ -24,7 +31,6 @@ import {
|
||||
type RelatedCaseSummary,
|
||||
createCaseRelationship,
|
||||
deleteCaseRelationship,
|
||||
fetchCaseRelationships,
|
||||
fetchCaseRelationshipSuggestions,
|
||||
searchCases,
|
||||
updateCaseRelationship,
|
||||
@@ -32,6 +38,8 @@ import {
|
||||
import {useAuthStore} from '../stores/auth'
|
||||
import {message} from '../utils/appMessage'
|
||||
import {formatDateTime, severityTag, statusTag, verdictTag} from '../utils/recordDisplay'
|
||||
import type {ResourceColumn, ResourceFilterConfig} from '../types/records'
|
||||
import DataTable from './DataTable'
|
||||
|
||||
type RelationshipDirection = 'current_to_other' | 'other_to_current'
|
||||
|
||||
@@ -54,6 +62,15 @@ const relationshipTypeOptions = [
|
||||
{label: 'Parent of', value: 'Parent of'},
|
||||
]
|
||||
|
||||
const relationshipFilters: ResourceFilterConfig[] = [
|
||||
{
|
||||
key: 'relationship_type',
|
||||
label: 'Relationship',
|
||||
valueType: 'select',
|
||||
options: relationshipTypeOptions,
|
||||
},
|
||||
]
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string) {
|
||||
const data = (error as {response?: {data?: unknown}}).response?.data
|
||||
if (typeof data === 'string') return data
|
||||
@@ -105,63 +122,60 @@ export default function CaseRelationshipsView({
|
||||
onOpenCase,
|
||||
onChanged,
|
||||
}: CaseRelationshipsViewProps) {
|
||||
const {token} = theme.useToken()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const canWrite = user?.role === 'admin' || user?.role === 'user'
|
||||
const [form] = Form.useForm<RelationshipFormValues>()
|
||||
const relationshipType = Form.useWatch('relationship_type', form)
|
||||
const [relationships, setRelationships] = useState<CaseRelationship[]>([])
|
||||
const [relationshipCount, setRelationshipCount] = useState(0)
|
||||
const [relationshipPage, setRelationshipPage] = useState(1)
|
||||
const [relationshipRefreshKey, setRelationshipRefreshKey] = useState(0)
|
||||
const [suggestions, setSuggestions] = useState<CaseRelationshipSuggestion[]>([])
|
||||
const [suggestionsOpen, setSuggestionsOpen] = useState(false)
|
||||
const [suggestionsError, setSuggestionsError] = useState('')
|
||||
const [caseOptions, setCaseOptions] = useState<RelatedCaseSummary[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [suggestionsLoading, setSuggestionsLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [editing, setEditing] = useState<CaseRelationship | null>(null)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const searchRequestRef = useRef(0)
|
||||
|
||||
const loadRelationships = useCallback(async () => {
|
||||
if (!caseId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await fetchCaseRelationships(caseId, relationshipPage)
|
||||
setRelationships(result.results)
|
||||
setRelationshipCount(result.count)
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error, 'Failed to load Case relationships'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [caseId, relationshipPage])
|
||||
const suggestionRequestRef = useRef(0)
|
||||
|
||||
const loadSuggestions = useCallback(async () => {
|
||||
if (!caseId) return
|
||||
const requestId = suggestionRequestRef.current + 1
|
||||
suggestionRequestRef.current = requestId
|
||||
setSuggestions([])
|
||||
setSuggestionsError('')
|
||||
setSuggestionsLoading(true)
|
||||
try {
|
||||
setSuggestions(await fetchCaseRelationshipSuggestions(caseId))
|
||||
const nextSuggestions = await fetchCaseRelationshipSuggestions(caseId)
|
||||
if (requestId === suggestionRequestRef.current) {
|
||||
setSuggestions(nextSuggestions)
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error, 'Failed to load related Case suggestions'))
|
||||
if (requestId === suggestionRequestRef.current) {
|
||||
setSuggestionsError(apiErrorMessage(error, 'Failed to load related Case suggestions'))
|
||||
}
|
||||
} finally {
|
||||
setSuggestionsLoading(false)
|
||||
if (requestId === suggestionRequestRef.current) {
|
||||
setSuggestionsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [caseId])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setRelationshipPage(1)
|
||||
}, [caseId])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadRelationships()
|
||||
void loadSuggestions()
|
||||
}, [loadRelationships, loadSuggestions])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await Promise.all([loadRelationships(), loadSuggestions()])
|
||||
const refreshRelationships = useCallback(() => {
|
||||
setRelationshipRefreshKey((current) => current + 1)
|
||||
onChanged?.()
|
||||
}, [loadRelationships, loadSuggestions, onChanged])
|
||||
}, [onChanged])
|
||||
|
||||
const openSuggestions = () => {
|
||||
if (suggestionsLoading) return
|
||||
setSuggestionsOpen(true)
|
||||
void loadSuggestions()
|
||||
}
|
||||
|
||||
const closeSuggestions = () => {
|
||||
setSuggestionsOpen(false)
|
||||
}
|
||||
|
||||
const loadCaseOptions = useCallback(async (search: string) => {
|
||||
const requestId = searchRequestRef.current + 1
|
||||
@@ -216,7 +230,7 @@ export default function CaseRelationshipsView({
|
||||
message.success('Case relationship created')
|
||||
}
|
||||
setModalOpen(false)
|
||||
await refresh()
|
||||
refreshRelationships()
|
||||
} catch (error) {
|
||||
if ((error as {errorFields?: unknown}).errorFields) return
|
||||
message.error(apiErrorMessage(error, 'Failed to save Case relationship'))
|
||||
@@ -229,13 +243,13 @@ export default function CaseRelationshipsView({
|
||||
try {
|
||||
await deleteCaseRelationship(relationship.id)
|
||||
message.success('Case relationship deleted')
|
||||
await refresh()
|
||||
refreshRelationships()
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error, 'Failed to delete Case relationship'))
|
||||
}
|
||||
}
|
||||
|
||||
const acceptSuggestion = async (suggestion: CaseRelationshipSuggestion) => {
|
||||
const acceptSuggestion = useCallback(async (suggestion: CaseRelationshipSuggestion) => {
|
||||
try {
|
||||
await createCaseRelationship({
|
||||
source_case_id: caseId,
|
||||
@@ -244,11 +258,12 @@ export default function CaseRelationshipsView({
|
||||
note: '',
|
||||
})
|
||||
message.success('Related Case added')
|
||||
await refresh()
|
||||
setSuggestions((current) => current.filter((item) => item.case.id !== suggestion.case.id))
|
||||
refreshRelationships()
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error, 'Failed to add related Case'))
|
||||
}
|
||||
}
|
||||
}, [caseId, refreshRelationships])
|
||||
|
||||
const caseSelectOptions = useMemo(
|
||||
() => caseOptions.map((item) => ({
|
||||
@@ -258,158 +273,264 @@ export default function CaseRelationshipsView({
|
||||
[caseOptions],
|
||||
)
|
||||
|
||||
const columns = [
|
||||
const relationshipColumns = useMemo<ResourceColumn<CaseRelationship>[]>(() => [
|
||||
{
|
||||
title: 'Relationship',
|
||||
key: 'relationship',
|
||||
dataIndex: 'relationship_type',
|
||||
width: 140,
|
||||
render: (_: unknown, row: CaseRelationship) => <Tag color="blue">{relationLabel(row, caseId)}</Tag>,
|
||||
defaultVisible: true,
|
||||
sorter: true,
|
||||
render: (value, row) => (
|
||||
<Tag color="blue">
|
||||
{row.source_case && row.target_case
|
||||
? relationLabel(row, caseId)
|
||||
: String(value || '')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Case',
|
||||
key: 'case',
|
||||
render: (_: unknown, row: CaseRelationship) => {
|
||||
const related = otherCase(row, caseId)
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Button type="link" style={{padding: 0}} onClick={() => onOpenCase?.(related.id)}>
|
||||
{related.case_id.toUpperCase()}
|
||||
</Button>
|
||||
<Typography.Text>{related.title}</Typography.Text>
|
||||
</Space>
|
||||
)
|
||||
title: 'Case ID',
|
||||
key: 'case_id',
|
||||
required: true,
|
||||
defaultVisible: true,
|
||||
fixed: 'left',
|
||||
width: 160,
|
||||
render: (_value, row) => otherCase(row, caseId).case_id.toUpperCase(),
|
||||
openResource: {
|
||||
resourceKey: 'cases',
|
||||
rowId: (row) => otherCase(row, caseId).id,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
key: 'title',
|
||||
defaultVisible: true,
|
||||
width: 360,
|
||||
render: (_value, row) => otherCase(row, caseId).title,
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
width: 130,
|
||||
render: (_: unknown, row: CaseRelationship) => statusTag(otherCase(row, caseId).status),
|
||||
defaultVisible: true,
|
||||
render: (_value, row) => statusTag(otherCase(row, caseId).status),
|
||||
},
|
||||
{
|
||||
title: 'Severity',
|
||||
key: 'severity',
|
||||
width: 120,
|
||||
render: (_: unknown, row: CaseRelationship) => severityTag(otherCase(row, caseId).severity),
|
||||
defaultVisible: true,
|
||||
render: (_value, row) => severityTag(otherCase(row, caseId).severity),
|
||||
},
|
||||
{
|
||||
title: 'Verdict',
|
||||
key: 'verdict',
|
||||
width: 150,
|
||||
render: (_: unknown, row: CaseRelationship) => verdictTag(otherCase(row, caseId).verdict),
|
||||
defaultVisible: true,
|
||||
render: (_value, row) => verdictTag(otherCase(row, caseId).verdict),
|
||||
},
|
||||
{
|
||||
title: 'Note',
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
ellipsis: true,
|
||||
render: (value: string) => value || '—',
|
||||
width: 280,
|
||||
defaultVisible: true,
|
||||
render: (value) => String(value || '—'),
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
key: 'created',
|
||||
dataIndex: 'created_at',
|
||||
width: 190,
|
||||
render: (_: unknown, row: CaseRelationship) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text>{formatDateTime(row.created_at)}</Typography.Text>
|
||||
<Typography.Text type="secondary">{row.created_by || 'System'}</Typography.Text>
|
||||
defaultVisible: true,
|
||||
sorter: true,
|
||||
render: (_value, row) => formatDateTime(row.created_at),
|
||||
},
|
||||
{
|
||||
title: 'Created By',
|
||||
key: 'created_by',
|
||||
dataIndex: 'created_by',
|
||||
width: 140,
|
||||
defaultVisible: true,
|
||||
render: (value) => String(value || 'System'),
|
||||
},
|
||||
], [caseId])
|
||||
|
||||
const suggestionColumns = useMemo<ColumnsType<CaseRelationshipSuggestion>>(() => [
|
||||
{
|
||||
title: 'Case',
|
||||
key: 'case',
|
||||
width: 360,
|
||||
render: (_value, suggestion) => (
|
||||
<div>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{padding: 0, height: 'auto'}}
|
||||
onClick={() => onOpenCase?.(suggestion.case.id)}
|
||||
>
|
||||
{suggestion.case.case_id.toUpperCase()}
|
||||
</Button>
|
||||
<Typography.Text ellipsis style={{display: 'block'}}>
|
||||
{suggestion.case.title}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Shared',
|
||||
dataIndex: 'shared_artifact_count',
|
||||
key: 'shared_artifact_count',
|
||||
width: 100,
|
||||
render: (count: number) => `${count} Artifact${count === 1 ? '' : 's'}`,
|
||||
},
|
||||
{
|
||||
title: 'Evidence',
|
||||
key: 'evidence',
|
||||
render: (_value, suggestion) => (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{suggestion.shared_artifacts.map((artifact) => (
|
||||
<Tag
|
||||
key={artifact.id}
|
||||
style={{maxWidth: '100%', whiteSpace: 'normal', overflowWrap: 'anywhere'}}
|
||||
>
|
||||
{artifact.type}: {artifact.value}
|
||||
</Tag>
|
||||
))}
|
||||
{suggestion.shared_artifact_count > suggestion.shared_artifacts.length && (
|
||||
<Typography.Text type="secondary">
|
||||
+{suggestion.shared_artifact_count - suggestion.shared_artifacts.length}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
...(canWrite ? [{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: CaseRelationship) => (
|
||||
<Space>
|
||||
<Tooltip title="Edit relationship">
|
||||
<Button type="text" icon={<EditOutlined />} onClick={() => openEdit(row)} />
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Delete this relationship?"
|
||||
okText="Delete"
|
||||
okButtonProps={{danger: true}}
|
||||
onConfirm={() => removeRelationship(row)}
|
||||
>
|
||||
<Tooltip title="Delete relationship">
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
render: (_value: unknown, suggestion: CaseRelationshipSuggestion) => (
|
||||
<Popconfirm
|
||||
title={`Relate ${suggestion.case.case_id.toUpperCase()} to this Case?`}
|
||||
onConfirm={() => acceptSuggestion(suggestion)}
|
||||
>
|
||||
<Button type="link" size="small">Add as Related</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
}] : []),
|
||||
]
|
||||
], [acceptSuggestion, canWrite, onOpenCase])
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{width: '100%'}}>
|
||||
<Space style={{width: '100%', justifyContent: 'flex-end'}}>
|
||||
{canWrite && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
Add relationship
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
<Table<CaseRelationship>
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={relationships}
|
||||
pagination={{
|
||||
current: relationshipPage,
|
||||
pageSize: 20,
|
||||
total: relationshipCount,
|
||||
showSizeChanger: false,
|
||||
onChange: setRelationshipPage,
|
||||
}}
|
||||
locale={{emptyText: <Empty description="No related Cases" />}}
|
||||
scroll={{x: 1100}}
|
||||
/>
|
||||
<Card title="Suggested by shared Artifacts" loading={suggestionsLoading}>
|
||||
<List
|
||||
dataSource={suggestions}
|
||||
locale={{emptyText: 'No suggestions'}}
|
||||
renderItem={(suggestion) => (
|
||||
<List.Item
|
||||
actions={canWrite ? [
|
||||
<Popconfirm
|
||||
key="relate"
|
||||
title={`Relate ${suggestion.case.case_id.toUpperCase()} to this Case?`}
|
||||
onConfirm={() => acceptSuggestion(suggestion)}
|
||||
>
|
||||
<Button type="link">Add as Related</Button>
|
||||
</Popconfirm>,
|
||||
] : undefined}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={(
|
||||
<Button type="link" style={{padding: 0}} onClick={() => onOpenCase?.(suggestion.case.id)}>
|
||||
{suggestion.case.case_id.toUpperCase()} / {suggestion.case.title}
|
||||
</Button>
|
||||
)}
|
||||
description={(
|
||||
<Space wrap>
|
||||
<Typography.Text>
|
||||
{suggestion.shared_artifact_count} shared Artifact{suggestion.shared_artifact_count === 1 ? '' : 's'}
|
||||
</Typography.Text>
|
||||
{suggestion.shared_artifacts.map((artifact) => (
|
||||
<Tag key={artifact.id}>{artifact.type}: {artifact.value}</Tag>
|
||||
))}
|
||||
{suggestion.shared_artifact_count > suggestion.shared_artifacts.length && (
|
||||
<Typography.Text type="secondary">
|
||||
+{suggestion.shared_artifact_count - suggestion.shared_artifacts.length}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
</List.Item>
|
||||
<>
|
||||
<div style={{height: '100%', padding: 16, boxSizing: 'border-box'}}>
|
||||
<DataTable<CaseRelationship>
|
||||
endpoint="/case-relationships/"
|
||||
tableKey={`case-relationships:${caseId}`}
|
||||
columns={relationshipColumns}
|
||||
filters={relationshipFilters}
|
||||
baseParams={{case: caseId}}
|
||||
refreshToken={relationshipRefreshKey}
|
||||
readOnly
|
||||
fillParent
|
||||
dense
|
||||
onOpenResource={(_resourceKey, rowId) => onOpenCase?.(String(rowId))}
|
||||
actions={(
|
||||
<Space size={4}>
|
||||
{canWrite && (
|
||||
<Tooltip title="Add relationship">
|
||||
<Button icon={<PlusOutlined />} onClick={openCreate} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Find suggestions">
|
||||
<Button
|
||||
icon={<SearchOutlined />}
|
||||
loading={suggestionsLoading}
|
||||
onClick={openSuggestions}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)}
|
||||
rowActions={canWrite ? (row) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="Edit relationship">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Delete this relationship?"
|
||||
description="This action cannot be undone."
|
||||
okText="Delete"
|
||||
okButtonProps={{danger: true}}
|
||||
onConfirm={() => removeRelationship(row)}
|
||||
>
|
||||
<Tooltip title="Delete relationship">
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
) : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<Modal
|
||||
title={(
|
||||
<span style={{display: 'inline-flex', alignItems: 'center', gap: 10}}>
|
||||
<SearchOutlined style={{color: token.colorPrimary}} />
|
||||
<span>Suggested Cases</span>
|
||||
</span>
|
||||
)}
|
||||
open={suggestionsOpen}
|
||||
footer={null}
|
||||
width="min(1280px, calc(100vw - 64px))"
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
container: {
|
||||
background: token.colorBgContainer,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
},
|
||||
header: {background: token.colorBgContainer},
|
||||
body: {background: token.colorBgContainer},
|
||||
}}
|
||||
onCancel={closeSuggestions}
|
||||
>
|
||||
{suggestionsError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={suggestionsError}
|
||||
action={(
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={suggestionsLoading}
|
||||
onClick={() => void loadSuggestions()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Table<CaseRelationshipSuggestion>
|
||||
rowKey={(suggestion) => suggestion.case.id}
|
||||
columns={suggestionColumns}
|
||||
dataSource={suggestions}
|
||||
loading={suggestionsLoading}
|
||||
size="small"
|
||||
tableLayout="fixed"
|
||||
pagination={false}
|
||||
locale={{emptyText: <Empty description="No suggestions" />}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title={editing ? 'Edit Case relationship' : 'Add Case relationship'}
|
||||
open={modalOpen}
|
||||
width={640}
|
||||
confirmLoading={saving}
|
||||
okText={editing ? 'Save' : 'Add'}
|
||||
onOk={() => void saveRelationship()}
|
||||
@@ -474,6 +595,6 @@ export default function CaseRelationshipsView({
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -249,6 +249,18 @@ const emptyTabs = {
|
||||
icon: <WandSparkles {...lucideIconProps}/>,
|
||||
render: (record: RecordRow, options?: RecordTabRenderOptions) => relatedEnrichmentsTable('case', record, options?.onOpenResource, options?.onChanged),
|
||||
},
|
||||
{
|
||||
key: 'related-cases',
|
||||
label: 'Related Cases',
|
||||
icon: <Link2 {...lucideIconProps}/>,
|
||||
render: (record: RecordRow, options?: RecordTabRenderOptions) => (
|
||||
<CaseRelationshipsView
|
||||
caseId={String(record.id || '')}
|
||||
onOpenCase={(caseId) => options?.onOpenResource?.('cases', caseId)}
|
||||
onChanged={options?.onChanged}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'knowledge',
|
||||
label: 'Knowledge',
|
||||
@@ -280,18 +292,6 @@ const emptyTabs = {
|
||||
icon: <FileTextOutlined/>,
|
||||
render: (record: RecordRow) => <CaseInvestigationView caseId={String(record.id || '')}/>,
|
||||
},
|
||||
{
|
||||
key: 'related-cases',
|
||||
label: 'Related Cases',
|
||||
icon: <Link2 {...lucideIconProps}/>,
|
||||
render: (record: RecordRow, options?: RecordTabRenderOptions) => (
|
||||
<CaseRelationshipsView
|
||||
caseId={String(record.id || '')}
|
||||
onOpenCase={(caseId) => options?.onOpenResource?.('cases', caseId)}
|
||||
onChanged={options?.onChanged}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
alert: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user