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:
funnywolf
2026-08-04 19:08:46 +08:00
co-authored by Copilot
parent 0ca5cf5e8e
commit 1b3a31c8eb
7 changed files with 354 additions and 204 deletions
+40 -8
View File
@@ -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
+9 -1
View File
@@ -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):
+19 -10
View File
@@ -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 追溯。
+1 -21
View File
@@ -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/',
+272 -151
View File
@@ -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>
</>
)
}
+12 -12
View File
@@ -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: [
{