main
vue 240 lines 7.32 KB
Raw
1 <template>
2 <div class="@container flex flex-col gap-4">
3 <!-- Header / actions -->
4 <div class="flex flex-col gap-2">
5 <div class="flex flex-wrap items-center justify-between gap-3">
6 <div class="flex items-center gap-3">
7 <h2>Template Library</h2>
8 <a
9 :href="REPO_URL"
10 target="_blank"
11 rel="noopener"
12 class="text-secondary text-sm"
13 title="Source repository on GitHub"
14 >
15 {{ REPO_NAME }}
16 </a>
17 </div>
18 <div class="flex items-center gap-2">
19 <div v-if="lastRefresh" class="text-tertiary text-xs">
20 Cached
21 {{ formatDate(lastRefresh, dFormats.datetimesec, { tz: true }) }}
22 </div>
23 <n-button size="small" secondary :loading="refreshing" @click="refresh">
24 <template #icon><Icon name="carbon:renew" /></template>
25 Refresh
26 </n-button>
27 </div>
28 </div>
29
30 <p class="text-secondary text-sm">
31 Read-only catalog of investigation playbooks. Click
32 <strong>Import</strong>
33 to materialise a playbook as a normal case template you can apply to cases. Edits made in CoPilot after
34 import don't flow back to the source repo, and changes pushed to the repo don't retroactively update
35 already-imported templates.
36 </p>
37
38 <n-alert v-if="invalidPaths.length" type="warning" :show-icon="false">
39 <template #header>{{ invalidPaths.length }} library file(s) failed validation</template>
40 <div class="flex flex-wrap gap-2 text-xs">
41 <code v-for="p of invalidPaths" :key="p">{{ p }}</code>
42 </div>
43 </n-alert>
44 </div>
45
46 <!-- Filter -->
47 <div class="mt-2">
48 <n-input
49 v-model:value="search"
50 size="small"
51 placeholder="Search by name, description, or source"
52 clearable
53 class="max-w-96"
54 >
55 <template #prefix><Icon name="carbon:search" /></template>
56 </n-input>
57 </div>
58
59 <n-spin :show="loading">
60 <div v-if="filteredEntries.length" class="grid grid-cols-1 gap-3 @3xl:grid-cols-2 @6xl:grid-cols-3">
61 <CardEntity v-for="entry of filteredEntries" :key="entry.key" size="small">
62 <template #headerMain>
63 <div class="text-default text-base font-semibold">
64 {{ entry.name }}
65 </div>
66 </template>
67 <template #headerExtra>
68 <n-button
69 size="small"
70 type="primary"
71 secondary
72 :disabled="importingKey !== null"
73 :loading="importingKey === entry.key"
74 @click="openImport(entry)"
75 >
76 <template #icon><Icon name="carbon:download" /></template>
77 Import
78 </n-button>
79 </template>
80 <template #default>
81 <p v-if="entry.description" class="text-secondary line-clamp-3 text-sm">
82 {{ entry.description }}
83 </p>
84 </template>
85
86 <template #footer>
87 <div class="flex flex-wrap items-center gap-2 text-xs">
88 <Badge type="splitted" size="small">
89 <template #label>Source</template>
90 <template #value>{{ entry.source || "" }}</template>
91 </Badge>
92 <Badge type="splitted" size="small">
93 <template #label>Tasks</template>
94 <template #value>{{ entry.tasks.length }}</template>
95 </Badge>
96 <Badge v-if="mandatoryCount(entry) > 0" color="warning" type="splitted" bright size="small">
97 <template #label>Mandatory</template>
98 <template #value>{{ mandatoryCount(entry) }}</template>
99 </Badge>
100 <n-tag
101 v-if="entry.match_field && entry.match_value"
102 :bordered="false"
103 size="small"
104 type="success"
105 :title="`Conditional: applies when ${entry.match_field} == ${entry.match_value}`"
106 >
107 {{ entry.match_field }} == {{ entry.match_value }}
108 </n-tag>
109 <n-tag v-for="tactic of mitreTactics(entry)" :key="tactic" size="small" :bordered="false">
110 {{ tactic }}
111 </n-tag>
112 </div>
113 </template>
114 </CardEntity>
115 </div>
116
117 <n-empty
118 v-else-if="!loading && entries.length === 0"
119 description="No library entries found. The repo may be empty or unreachable."
120 class="h-40 justify-center"
121 >
122 <template #extra>
123 <n-button size="small" @click="refresh">Retry</n-button>
124 </template>
125 </n-empty>
126
127 <n-empty v-else-if="!loading" description="No entries match your search." class="h-32 justify-center" />
128 </n-spin>
129
130 <CaseTemplateLibraryImportModal v-model:show="showImport" :entry="selectedEntry" @imported="onImported" />
131 </div>
132 </template>
133
134 <script setup lang="ts">
135 import type { CaseTemplateLibraryEntry } from "@/types/incidentManagement/caseTemplates.d"
136 import { NAlert, NButton, NEmpty, NInput, NSpin, NTag, useMessage } from "naive-ui"
137 import { computed, onBeforeMount, ref } from "vue"
138 import Api from "@/api"
139 import Badge from "@/components/common/Badge.vue"
140 import CardEntity from "@/components/common/cards/CardEntity.vue"
141 import Icon from "@/components/common/Icon.vue"
142 import { useSettingsStore } from "@/stores/settings"
143 import { formatDate } from "@/utils/format"
144 import CaseTemplateLibraryImportModal from "./CaseTemplateLibraryImportModal.vue"
145
146 const emit = defineEmits<{
147 (e: "imported"): void
148 }>()
149
150 const REPO_NAME = "socfortress/CoPilot-Case-Templates"
151 const REPO_URL = `https://github.com/${REPO_NAME}`
152
153 const message = useMessage()
154 const dFormats = useSettingsStore().dateFormat
155
156 const entries = ref<CaseTemplateLibraryEntry[]>([])
157 const invalidPaths = ref<string[]>([])
158 const lastRefresh = ref<string | null>(null)
159 const loading = ref(false)
160 const refreshing = ref(false)
161 const importingKey = ref<string | null>(null)
162 const search = ref<string | null>(null)
163
164 const showImport = ref(false)
165 const selectedEntry = ref<CaseTemplateLibraryEntry | null>(null)
166
167 const filteredEntries = computed(() => {
168 const q = (search.value || "").trim().toLowerCase()
169
170 if (!q) return entries.value
171
172 return entries.value.filter(e => {
173 const haystack = `${e.name} ${e.description ?? ""} ${e.source ?? ""}`.toLowerCase()
174 return haystack.includes(q)
175 })
176 })
177
178 function mandatoryCount(entry: CaseTemplateLibraryEntry): number {
179 return entry.tasks.filter(t => t.mandatory).length
180 }
181
182 function mitreTactics(entry: CaseTemplateLibraryEntry): string[] {
183 const raw = entry.tags?.mitre_tactics
184 if (!Array.isArray(raw)) return []
185 return raw.filter((t): t is string => typeof t === "string")
186 }
187
188 function load() {
189 loading.value = true
190
191 Api.incidentManagement.caseTemplates
192 .getLibrary()
193 .then(res => {
194 entries.value = res.data.entries || []
195 invalidPaths.value = res.data.invalid_paths || []
196 lastRefresh.value = res.data.last_refresh || null
197
198 if (!res.data.success) {
199 message.warning(res.data.message || "Failed to load case-template library")
200 }
201 })
202 .catch(err => {
203 message.error(err.response?.data?.message || "Failed to load case-template library")
204 })
205 .finally(() => {
206 loading.value = false
207 })
208 }
209
210 async function refresh() {
211 refreshing.value = true
212
213 try {
214 const res = await Api.incidentManagement.caseTemplates.refreshLibrary()
215 if (res.data.success) {
216 message.success(res.data.message)
217 } else {
218 message.warning(res.data.message)
219 }
220 load()
221 } catch (err: any) {
222 message.error(err.response?.data?.message || "Failed to refresh case-template library")
223 } finally {
224 refreshing.value = false
225 }
226 }
227
228 function openImport(entry: CaseTemplateLibraryEntry) {
229 selectedEntry.value = entry
230 showImport.value = true
231 }
232
233 function onImported() {
234 // Bubble up so the parent (CaseTemplatesList) can refresh its own list
235 // and switch the user back to the Templates tab.
236 emit("imported")
237 }
238
239 onBeforeMount(load)
240 </script>