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: [
           {