From 8fe460a0940263102fbf99f45290ce942875150d Mon Sep 17 00:00:00 2001 From: abugraokkali <abugraokkali@outlook.com> Date: Wed, 27 Dec 2023 13:24:27 +0000 Subject: [PATCH] feature: Discovery operations (list,create,run,delete) --- db.json | 8 + frontend/src/components/Select/Profile.vue | 74 ++++++++ frontend/src/components/Table/AsyncStore.vue | 8 +- frontend/src/localization/en.json | 52 ++++++ frontend/src/localization/tr.json | 46 +++++ frontend/src/models/Asset.ts | 29 ++-- frontend/src/models/Column.ts | 6 +- frontend/src/models/Discovery.ts | 18 ++ frontend/src/models/Profile.ts | 6 +- frontend/src/router/index.ts | 5 + frontend/src/stores/discovery.ts | 106 ++++++++++++ frontend/src/utils/format-date.ts | 2 +- frontend/src/views/modals/Discovery.vue | 80 +++++++++ frontend/src/views/pages/Discoveries.vue | 167 +++++++++++++++++++ frontend/src/views/pages/Profiles.vue | 3 +- 15 files changed, 585 insertions(+), 25 deletions(-) create mode 100644 frontend/src/components/Select/Profile.vue create mode 100644 frontend/src/models/Discovery.ts create mode 100644 frontend/src/stores/discovery.ts create mode 100644 frontend/src/views/modals/Discovery.vue create mode 100644 frontend/src/views/pages/Discoveries.vue diff --git a/db.json b/db.json index 0562b19..2ead20c 100755 --- a/db.json +++ b/db.json @@ -32,6 +32,14 @@ "url": "#/assets", "key": "assets" }, + { + "name": { + "tr": "Keşifler", + "en": "Discoveries" + }, + "url": "#/discoveries", + "key": "discoveries" + }, { "name": { "tr": "Profiller", diff --git a/frontend/src/components/Select/Profile.vue b/frontend/src/components/Select/Profile.vue new file mode 100644 index 0000000..84095b6 --- /dev/null +++ b/frontend/src/components/Select/Profile.vue @@ -0,0 +1,74 @@ +<script setup lang="ts"> +import { useProfileStore } from "@/stores/profile" +import type { IProfile } from "@/models/Profile" +import { computed, onMounted, ref } from "vue" +import { useI18n } from "vue-i18n" + +const store = useProfileStore() +const { t } = useI18n() + +const props = defineProps<{ + profile: IProfile +}>() + +const emit = defineEmits<{ + (event: "update:profile", ...args: any[]): void +}>() + +const value = computed({ + get() { + return props.profile + }, + set(value) { + emit("update:profile", value) + }, +}) + +const createDebounce = () => { + let timeout: number | undefined = 0 + return function (fnc: () => void, delayMs: any) { + clearTimeout(timeout) + timeout = setTimeout(() => { + fnc() + }, delayMs || 500) + } +} +const searchDebounce = createDebounce() + +const loading = ref(true) +const search = (query: string) => { + loading.value = true + searchDebounce(() => { + store + .fetch({ + search: query, + per_page: 20, + }) + .then(() => { + loading.value = false + }) + }, 300) +} + +onMounted(() => { + store.fetch().then(() => { + loading.value = false + }) +}) +</script> + +<template> + <n-select + v-model:value="value" + filterable + :placeholder="t('profile.select.placeholder')" + :options="store.get.records" + :loading="loading" + clearable + remote + :clear-filter-after-select="false" + @search="search" + label-field="name" + value-field="id" + /> +</template> diff --git a/frontend/src/components/Table/AsyncStore.vue b/frontend/src/components/Table/AsyncStore.vue index 2883a4e..07472c9 100755 --- a/frontend/src/components/Table/AsyncStore.vue +++ b/frontend/src/components/Table/AsyncStore.vue @@ -184,16 +184,16 @@ const handlePageChange = (currentPage: any) => { query(currentPage) } -const handleFilterChange = (filters: any) => { +const handleFilterChange = (filter: any) => { loading.value = true - Object.keys(filters).forEach((item) => { + Object.keys(filter).forEach((item) => { filters.value = filters.value.filter((i: IData) => { return i.key != item }) - if (filters[item] && filters[item].length > 0) { + if (filter[item] && filter[item].length > 0) { filters.value.push({ key: item, - value: filters[item], + value: filter[item], }) } }) diff --git a/frontend/src/localization/en.json b/frontend/src/localization/en.json index 41d9101..515189a 100755 --- a/frontend/src/localization/en.json +++ b/frontend/src/localization/en.json @@ -43,6 +43,9 @@ "name": "Name", "updated_at": "Updated At" }, + "select": { + "placeholder": "Type to search profile" + }, "fetch": { "messages": { "error": "An error occurred while fetching profiles." @@ -78,5 +81,54 @@ "error": "An error occurred while deleting profile." } } + }, + "discovery": { + "title": "Discoveries", + "description": "You can start a new discovery or view your previous discoveries here.", + "table": { + "ip_range": "IP Range", + "profile": "Profile", + "discovery_status": "Discovery Status", + "message": "Message", + "updated_at": "Last Scan" + }, + "status": { + "pending": "Pending", + "in_progress": "In Progress", + "done": "Done", + "error": "Error" + }, + "fetch": { + "messages": { + "error": "An error occurred while fetching discoveries." + } + }, + "create": { + "title": "Create Discovery", + "inputs": { + "ip_range": "IP Range", + "profile": "Profile" + }, + "rules": { + "ip_range": "IP Range is required.", + "profile": "Profile is required." + }, + "messages": { + "success": "Discovery created successfully.", + "error": "An error occurred while creating discovery." + } + }, + "run": { + "messages": { + "success": "Scan started successfully.", + "error": "An error occurred while starting scan." + } + }, + "delete": { + "messages": { + "success": "Discovery deleted successfully.", + "error": "An error occurred while deleting discovery." + } + } } } diff --git a/frontend/src/localization/tr.json b/frontend/src/localization/tr.json index 8541f0d..ec9bd57 100755 --- a/frontend/src/localization/tr.json +++ b/frontend/src/localization/tr.json @@ -43,6 +43,9 @@ "name": "Ad", "updated_at": "Güncellenme Tarihi" }, + "select": { + "placeholder": "Profil aramak için yazın" + }, "fetch": { "messages": { "error": "Profiller getirilirken bir hata oluştu." @@ -78,5 +81,48 @@ "error": "Profil silinirken bir hata oluştu." } } + }, + "discovery": { + "title": "Keşifler", + "description": "Burada yeni bir keşif başlatabilir veya önceki keşiflerinizi görüntüleyebilirsiniz.", + "table": { + "ip_range": "IP Aralığı", + "profile": "Profil", + "discovery_status": "Keşif Durumu", + "message": "Mesaj", + "updated_at": "Son Tarama" + }, + "fetch": { + "messages": { + "error": "Keşifler getirilirken bir hata oluştu." + } + }, + "create": { + "title": "Keşif Oluştur", + "inputs": { + "ip_range": "IP Aralığı", + "profile": "Profil" + }, + "rules": { + "ip_range": "IP Aralığı zorunludur.", + "profile": "Profil zorunludur." + }, + "messages": { + "success": "Keşif başarıyla oluşturuldu.", + "error": "Keşif oluşturulurken bir hata oluştu." + } + }, + "run": { + "messages": { + "success": "Tarama başarıyla başlatıldı.", + "error": "Tarama başlatılırken bir hata oluştu." + } + }, + "delete": { + "messages": { + "success": "Keşif başarıyla silindi.", + "error": "Keşif silinirken bir hata oluştu." + } + } } } diff --git a/frontend/src/models/Asset.ts b/frontend/src/models/Asset.ts index 59ed535..0c96962 100644 --- a/frontend/src/models/Asset.ts +++ b/frontend/src/models/Asset.ts @@ -1,15 +1,16 @@ +import type { IDiscovery } from "./Discovery" + export interface IAsset { - id: string - created_at: string - updated_at: string - deleted_at: string - hostname: string - address: string - serial_number: string - vendor: string - model: string - discovery_id: string - discovery: any - packages: any - } - \ No newline at end of file + id: string + created_at: string + updated_at: string + deleted_at: string + hostname: string + address: string + serial_number: string + vendor: string + model: string + discovery_id: string + discovery: IDiscovery + packages: any +} diff --git a/frontend/src/models/Column.ts b/frontend/src/models/Column.ts index c7e0740..876e987 100755 --- a/frontend/src/models/Column.ts +++ b/frontend/src/models/Column.ts @@ -7,13 +7,13 @@ export interface IColumn { ellipsis?: { tooltip: boolean } - render?: (record: any) => JSX.Element + render?: (record: any) => any type?: string options?: string[] - filter?: (value: any) => boolean + filter?: (value: any, row: any) => boolean filterOptions?: any customFilter?: boolean defaultFilterOptionValues?: any - renderExpand?: (record: any) => JSX.Element + renderExpand?: (record: any) => any resizable?: boolean } diff --git a/frontend/src/models/Discovery.ts b/frontend/src/models/Discovery.ts new file mode 100644 index 0000000..325e5e7 --- /dev/null +++ b/frontend/src/models/Discovery.ts @@ -0,0 +1,18 @@ +import type { IProfile } from "./Profile" + +export interface IDiscovery { + id: string + created_at: string + updated_at: string + deleted_at: string + ip_range: string + profile_id: string + profile: IProfile + discovery_status: string + message: string +} + +export interface IDiscoveryCreate { + ip_range: string + profile_id: string | null +} diff --git a/frontend/src/models/Profile.ts b/frontend/src/models/Profile.ts index dc9cd04..724f863 100644 --- a/frontend/src/models/Profile.ts +++ b/frontend/src/models/Profile.ts @@ -1,12 +1,14 @@ +import type { IDiscovery } from "./Discovery" + export interface IProfile { id: string created_at: string updated_at: string - deleted_at: any + deleted_at: string name: string username: string password: string - discoveries: any[] + discoveries: IDiscovery[] } export interface IProfileCreate { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 0513f30..1dfd648 100755 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -8,6 +8,11 @@ const router = createRouter({ name: "assets", component: () => import("@/views/pages/Assets.vue"), }, + { + path: "/discoveries", + name: "discoveries", + component: () => import("@/views/pages/Discoveries.vue"), + }, { path: "/profiles", name: "profiles", diff --git a/frontend/src/stores/discovery.ts b/frontend/src/stores/discovery.ts new file mode 100644 index 0000000..503cfe6 --- /dev/null +++ b/frontend/src/stores/discovery.ts @@ -0,0 +1,106 @@ +import { defineStore } from "pinia" +import http from "@/utils/http-common" +import { i18n } from "@/utils/i18n" +import type { IFilter } from "@/models/Filter" +import type { IPaginator } from "@/models/Paginator" +import type { IDiscovery, IDiscoveryCreate } from "@/models/Discovery" + +export const useDiscoveryStore = defineStore({ + id: "discovery", + state: () => ({ + filter: {} as IFilter, + discoveries: {} as IPaginator<IDiscovery>, + }), + getters: { + get: (state) => state.discoveries, + }, + actions: { + async fetch(payload: IFilter = {} as IFilter) { + let q = payload + if (Object.keys(payload).length < 1) { + q = this.filter + } else { + this.filter = q + } + const query = new URLSearchParams(q as Record<string, string>).toString() + + return http.get(`discoveries/?${query}`).then((res) => { + if (res.status == 200) { + this.discoveries = res.data + } else { + window.$notification.error({ + title: i18n.t("common.error"), + content: i18n.t("discovery.fetch.messages.error"), + duration: 5000, + }) + } + }) + }, + async create(payload: IDiscoveryCreate) { + return http + .post(`discoveries`, { + data: JSON.stringify(payload), + }) + .then((res) => { + if (res.status == 200) { + window.$notification.success({ + title: i18n.t("common.success"), + content: i18n.t("discovery.create.messages.success"), + duration: 3000, + }) + this.fetch() + } else { + window.$notification.error({ + title: i18n.t("common.error"), + content: i18n.t("discovery.create.messages.error"), + duration: 5000, + }) + } + }) + }, + async run(id: string) { + return http.post(`discoveries/${id}`).then((res) => { + if (res.status == 200) { + window.$notification.success({ + title: i18n.t("common.success"), + content: i18n.t("discovery.run.messages.success"), + duration: 3000, + }) + this.fetch() + } else { + window.$notification.error({ + title: i18n.t("common.error"), + content: i18n.t("discovery.run.messages.error"), + duration: 5000, + }) + } + }) + }, + async delete(id: string) { + window.$dialog.warning({ + title: i18n.t("common.warning"), + content: i18n.t("common.are_you_sure"), + positiveText: i18n.t("common.yes"), + negativeText: i18n.t("common.no"), + onPositiveClick: () => { + return http.delete(`discoveries/${id}`).then((res) => { + if (res.status == 200) { + window.$notification.success({ + title: i18n.t("common.success"), + content: i18n.t("discovery.delete.messages.success"), + duration: 3000, + }) + this.fetch() + } else { + window.$notification.error({ + title: i18n.t("common.error"), + content: i18n.t("discovery.delete.messages.error"), + duration: 5000, + }) + } + }) + }, + }) + }, + }, +}) diff --git a/frontend/src/utils/format-date.ts b/frontend/src/utils/format-date.ts index a492b27..ebcb0fd 100755 --- a/frontend/src/utils/format-date.ts +++ b/frontend/src/utils/format-date.ts @@ -1,7 +1,7 @@ import { format } from "date-fns" import { tr, enUS } from "date-fns/locale" -const formatDate = (time: any) => { +const formatDate = (time: string) => { return format(new Date(time), "dd MMMM yyyy HH:mm", { locale: document.documentElement.lang == "tr" ? tr : enUS, }) diff --git a/frontend/src/views/modals/Discovery.vue b/frontend/src/views/modals/Discovery.vue new file mode 100644 index 0000000..9673847 --- /dev/null +++ b/frontend/src/views/modals/Discovery.vue @@ -0,0 +1,80 @@ +<script setup lang="ts"> +import { ref } from "vue" +import { useI18n } from "vue-i18n" +import type { FormInst } from "naive-ui" +import { useDiscoveryStore } from "@/stores/discovery" +import useEmitter from "@/utils/emitter" +import type { IDiscoveryCreate } from "@/models/Discovery" +import ProfileSelect from "@/components/Select/Profile.vue" + +const { t } = useI18n() +const emitter = useEmitter() +const store = useDiscoveryStore() + +const formRef = ref<FormInst | null>(null) +const show = ref(false) + +const values = ref<IDiscoveryCreate>({ + ip_range: "", + profile_id: null, +}) + +emitter.on("showDiscoveryModal", () => { + show.value = true +}) + +const rules = { + ip_range: { + required: true, + trigger: "input", + message: t("discovery.create.rules.ip_range"), + }, + profile_id: { + required: true, + trigger: "input", + message: t("discovery.create.rules.profile"), + }, +} + +const submit = (e: MouseEvent) => { + e.preventDefault() + formRef.value?.validate(async (errors) => { + if (!errors) { + await store.create(values.value) + show.value = false + } + }) +} +</script> + +<template> + <n-drawer v-model:show="show" :width="500"> + <n-drawer-content :title="t('discovery.create.title')"> + <n-form :model="values" ref="formRef" :rules="rules"> + <n-form-item + :label="t('discovery.create.inputs.ip_range')" + path="ip_range" + > + <n-input + v-model:value="values.ip_range" + clearable + placeholder="127.0.0.1-255" + /> + </n-form-item> + <n-form-item + :label="t('discovery.create.inputs.profile')" + path="profile_id" + > + <ProfileSelect v-model:profile="values.profile_id" /> + </n-form-item> + </n-form> + <template #footer> + <n-space justify="end"> + <n-button type="primary" @click="submit"> + {{ t("common.create") }} + </n-button> + </n-space> + </template> + </n-drawer-content> + </n-drawer> +</template> diff --git a/frontend/src/views/pages/Discoveries.vue b/frontend/src/views/pages/Discoveries.vue new file mode 100644 index 0000000..60eb66c --- /dev/null +++ b/frontend/src/views/pages/Discoveries.vue @@ -0,0 +1,167 @@ +<script setup lang="ts"> +import { h, reactive } from "vue" +import { useI18n } from "vue-i18n" +import AsyncStore from "@/components/Table/AsyncStore.vue" +import DropdownMenu from "@/components/Table/DropdownMenu.vue" +import DiscoveryModal from "@/views/modals/Discovery.vue" +import { useDiscoveryStore } from "@/stores/discovery" +import useEmitter from "@/utils/emitter" +import type { IColumn } from "@/models/Column" +import type { IDiscovery } from "@/models/Discovery" +import Header from "@/components/UIElements/Header.vue" +import { NButton, NTag } from "naive-ui" + +const { t } = useI18n() +const store = useDiscoveryStore() +const emitter = useEmitter() + +const columns: IColumn[] = reactive([ + { + title: t("discovery.table.ip_range"), + key: "ip_range", + filterable: true, + sorter: "default", + resizable: true, + ellipsis: { + tooltip: true, + }, + }, + { + title: t("discovery.table.profile"), + key: "profile.name", + resizable: true, + ellipsis: { + tooltip: true, + }, + }, + { + title: t("discovery.table.discovery_status"), + key: "discovery_status", + sorter: "default", + resizable: true, + ellipsis: { + tooltip: true, + }, + filterOptions: [ + { label: t(`discovery.status.in_progress`), value: "in_progress" }, + { label: t(`discovery.status.pending`), value: "pending" }, + { label: t(`discovery.status.done`), value: "done" }, + { label: t(`discovery.status.error`), value: "error" }, + ], + filter(value: string, row: IDiscovery) { + return row.discovery_status === value + }, + render: (row: IDiscovery) => { + if ( + row.discovery_status == "in_progress" || + row.discovery_status == "pending" + ) { + return h( + NTag, + { type: "info" }, + { + default: () => [ + h("i", { class: "fas fa-spinner fa-spin mr-2" }), + t(`discovery.status.${row.discovery_status}`), + ], + }, + ) + } else if (row.discovery_status == "done") { + return h( + NTag, + { type: "success" }, + { + default: () => [ + h("i", { class: "fas fa-circle-check mr-2" }), + t(`discovery.status.${row.discovery_status}`), + ], + }, + ) + } else if (row.discovery_status == "error") { + return h( + NTag, + { type: "error" }, + { + default: () => [ + h("i", { class: "fas fa-circle-exclamation mr-2" }), + t(`discovery.status.${row.discovery_status}`), + ], + }, + ) + } + }, + }, + { + title: t("discovery.table.message"), + key: "message", + filterable: true, + sorter: "default", + ellipsis: { + tooltip: true, + }, + }, + { + title: t("discovery.table.updated_at"), + key: "updated_at", + sorter: "default", + resizable: true, + ellipsis: { + tooltip: true, + }, + type: "date", + }, + { + key: "actions", + width: 180, + render: (row: IDiscovery) => { + return [ + h( + NButton, + { + size: "small", + onClick: () => { + store.run(row.id) + }, + }, + { + default: () => [ + h("i", { class: "fas fa-bullseye mr-2" }), + h("span", "Tarama Başlat"), + ], + }, + ), + h(DropdownMenu, { + options: [ + { + label: t("common.delete"), + key: "delete", + icon: () => h("i", { class: "fas fa-trash-can" }), + props: { + onClick: () => { + store.delete(row.id) + }, + }, + }, + ], + }), + ] + }, + }, +]) +</script> + +<template> + <Header + :title="t('discovery.title')" + :description="t('discovery.description')" + /> + <AsyncStore :dispatcher="store.fetch" :data="store.get" :columns="columns"> + <template #buttons> + <n-button type="primary" @click="emitter.emit('showDiscoveryModal')"> + <i class="fas fa-plus mr-2" /> + {{ t("discovery.create.title") }} + </n-button> + </template> + </AsyncStore> + <DiscoveryModal /> +</template> diff --git a/frontend/src/views/pages/Profiles.vue b/frontend/src/views/pages/Profiles.vue index f73edf4..0151b40 100644 --- a/frontend/src/views/pages/Profiles.vue +++ b/frontend/src/views/pages/Profiles.vue @@ -7,6 +7,7 @@ import ProfileModal from "@/views/modals/Profile.vue" import { useProfileStore } from "@/stores/profile" import useEmitter from "@/utils/emitter" import type { IColumn } from "@/models/Column" +import type { IProfile } from "@/models/Profile" import Header from "@/components/UIElements/Header.vue" const { t } = useI18n() @@ -37,7 +38,7 @@ const columns: IColumn[] = reactive([ { key: "actions", width: 40, - render: (row: any) => { + render: (row: IProfile) => { return h(DropdownMenu, { options: [ {