Dashboard and error handling
This commit is contained in:
113
packages/sshecret-frontend/src/views/Dashboard.vue
Normal file
113
packages/sshecret-frontend/src/views/Dashboard.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="px-4 pt-6">
|
||||
<div class="grid w-full grid-cols-1 gap-4 mt-4 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
<div
|
||||
class="items-center justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800"
|
||||
id="dashboard-stats-panel"
|
||||
>
|
||||
<div class="w-full">
|
||||
<h3 class="text-base text-gray-500 dark:text-gray-400">Stats</h3>
|
||||
<dl
|
||||
class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700"
|
||||
v-if="stats"
|
||||
>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 text-xs dark:text-gray-400">Clients</dt>
|
||||
<dd class="text-lg font-semibold" id="stats-client-count">
|
||||
{{ stats.clients }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col py-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Secrets</dt>
|
||||
<dd class="text-lg font-semibold" id="stats-secret-count">
|
||||
{{ stats.secrets }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col py-3">
|
||||
<dt class="mb-1 text-gray-500 md:text-xs dark:text-gray-400">Audit Events</dt>
|
||||
<dd class="text-lg font-semibold" id="stats-audit-count">
|
||||
{{ stats.audit_events }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<dl
|
||||
class="max-w-md text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700"
|
||||
v-else
|
||||
>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 text-xs dark:text-gray-400">
|
||||
<sl-skeleton effect="pulse" class="stat-skeleton"></sl-skeleton>
|
||||
</dt>
|
||||
<dd class="text-lg font-semibold" id="stats-client-count">
|
||||
<sl-skeleton class="stat-val-skeleton" effect="pulse"></sl-skeleton>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 text-xs dark:text-gray-400">
|
||||
<sl-skeleton effect="pulse" class="stat-skeleton"></sl-skeleton>
|
||||
</dt>
|
||||
<dd class="text-lg font-semibold" id="stats-client-count">
|
||||
<sl-skeleton class="stat-val-skeleton" effect="pulse"></sl-skeleton>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col pb-3">
|
||||
<dt class="mb-1 text-gray-500 text-xs dark:text-gray-400">
|
||||
<sl-skeleton effect="pulse" class="stat-skeleton"></sl-skeleton>
|
||||
</dt>
|
||||
<dd class="text-lg font-semibold" id="stats-client-count">
|
||||
<sl-skeleton class="stat-val-skeleton" effect="pulse"></sl-skeleton>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="items-center 2xl: col-span-2 xl:col-span-2 justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800"
|
||||
>
|
||||
<div class="w-full">
|
||||
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">Last Login Events</h3>
|
||||
<AuditTable :auditFilter="loginAuditFilter" :paginate="false"></AuditTable>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="items-center 2xl:col-span-3 xl:col-span-3 justify-between p-4 bg-white border border-gray-200 rounded-lg shadow-sm sm:flex dark:border-gray-700 sm:p-6 dark:bg-gray-800"
|
||||
>
|
||||
<div class="w-full">
|
||||
<h3 class="text-base font-normal text-gray-500 dark:text-gray-400">Last Audit Events</h3>
|
||||
<AuditTable :auditFilter="auditFilter" :paginate="false"></AuditTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { SystemStats } from '@/client'
|
||||
import type { AuditFilter } from '@/api/types'
|
||||
import { SshecretAdmin } from '@/client'
|
||||
import AuditTable from '@/components/audit/AuditTable.vue'
|
||||
import { useAlertsStore } from '@/store/useAlertsStore'
|
||||
|
||||
const loginAuditFilter = {
|
||||
operation: 'login',
|
||||
limit: 10,
|
||||
}
|
||||
|
||||
const auditFilter = { limit: 10 }
|
||||
|
||||
const stats = ref<SystemStats>({})
|
||||
|
||||
const alerts = useAlertsStore()
|
||||
|
||||
async function loadStats() {
|
||||
const response = await SshecretAdmin.getSystemStatsApiV1StatsGet()
|
||||
if (response.data) {
|
||||
stats.value = response.data
|
||||
} else {
|
||||
alerts.showAlert('Unable to fetch stats', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
</script>
|
||||
@ -7,7 +7,12 @@ import ClientDetailView from '@/views/clients/ClientDetailView.vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useTreeState } from '@/store/useTreeState'
|
||||
|
||||
const props = defineProps<{ id: string | null; parentId: string | null }>()
|
||||
interface Props {
|
||||
id: string
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const router = useRouter()
|
||||
const clientId = toRef(() => props.id)
|
||||
const parentId = toRef(() => props.parentId)
|
||||
|
||||
@ -11,25 +11,30 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, toRef, watch, onMounted } from 'vue'
|
||||
import { assertSdkResponseOk } from '@/api/AssertSdkResponseOk'
|
||||
import { ValidationError } from '@/api/errors'
|
||||
import ClientSkeleton from '@/components/clients/ClientSkeleton.vue'
|
||||
import ClientDetail from '@/components/clients/ClientDetail.vue'
|
||||
import type { ClientCreate } from '@/client'
|
||||
import { idKey } from '@/api/paths'
|
||||
import { SshecretAdmin } from '@/client'
|
||||
import { useTreeState } from '@/store/useTreeState'
|
||||
import { useAlertsStore } from '@/store/useAlertsStore'
|
||||
|
||||
const props = defineProps<{ id: string | null; parentId: string | null }>()
|
||||
|
||||
interface Props {
|
||||
id: string
|
||||
parentId?: string
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const clientId = toRef(() => props.id)
|
||||
|
||||
const client = ref<Client>()
|
||||
|
||||
const treeState = useTreeState()
|
||||
|
||||
const emit = defineEmits<{ (e: 'clientDeleted', data: string): void }>()
|
||||
const alerts = useAlertsStore()
|
||||
|
||||
const updateErrors = ref([])
|
||||
|
||||
async function loadClient() {
|
||||
console.log('loadClient called: ', props.id)
|
||||
if (!props.id) return
|
||||
client.value = await treeState.getClient(props.id)
|
||||
}
|
||||
@ -46,12 +51,27 @@ async function deleteClient(deleteId: string) {
|
||||
emit('clientDeleted', deleteId)
|
||||
}
|
||||
|
||||
function clearUpdateErrors() {
|
||||
updateErrors.value = []
|
||||
}
|
||||
|
||||
async function updateClient(updated: ClientCreate) {
|
||||
const response = await SshecretAdmin.updateClientApiV1ClientsIdPut({
|
||||
path: { id: idKey(localClient.value.id) },
|
||||
body: data,
|
||||
})
|
||||
client.value = response.data
|
||||
try {
|
||||
const responseData = assertSdkResponseOk(response)
|
||||
client.value = responseData
|
||||
clearUpdateErrors()
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) {
|
||||
updateErrors.value = err.errors
|
||||
} else {
|
||||
const errorMessage = err.message ?? 'Unknown error'
|
||||
alerts.showAlert(`Error from backend: ${errorMessage}`, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadClient)
|
||||
|
||||
@ -88,7 +88,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<sl-drawer label="Create Client" :open="createDrawerOpen" @sl-hide="createDrawerOpen = false">
|
||||
<ClientForm @submit="createClient" @cancel="createDrawerOpen = false" :key="createFormKey" />
|
||||
<ClientForm
|
||||
@submit="createClient"
|
||||
@cancel="createDrawerOpen = false"
|
||||
@clearErrors="clearCreateErrors"
|
||||
:key="createFormKey"
|
||||
:errors="createErrors"
|
||||
/>
|
||||
</sl-drawer>
|
||||
</template>
|
||||
|
||||
@ -101,6 +107,8 @@ import { usePagination } from '@/composables/usePagination'
|
||||
import { SshecretAdmin } from '@/client/sdk.gen'
|
||||
|
||||
import type { Client, ClientCreate } from '@/client/types.gen'
|
||||
import { ValidationError } from '@/api/errors'
|
||||
import { assertSdkResponseOk } from '@/api/AssertSdkResponseOk'
|
||||
|
||||
import { useTreeState } from '@/store/useTreeState'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
@ -110,6 +118,7 @@ import ClientSecretTreeItem from '@/components/clients/ClientSecretTreeItem.vue'
|
||||
import ClientForm from '@/components/clients/ClientForm.vue'
|
||||
import PageNumbers from '@/components/common/PageNumbers.vue'
|
||||
import TreeItemSkeleton from '@/components/common/TreeItemSkeleton.vue'
|
||||
import { useAlertsStore } from '@/store/useAlertsStore'
|
||||
|
||||
import { useDebounce } from '@/composables/useDebounce'
|
||||
const treeState = useTreeState()
|
||||
@ -121,6 +130,8 @@ const clients = computed(() => treeState.clients.clients)
|
||||
const selectedClient = ref<Client | null>(null)
|
||||
const selectedSecret = ref<string | null>(null)
|
||||
|
||||
const createErrors = ref([])
|
||||
|
||||
const createFormKey = ref<number>(0)
|
||||
const createDrawerOpen = ref<boolean>(false)
|
||||
|
||||
@ -135,6 +146,7 @@ const router = useRouter()
|
||||
const clientQuery = toRef(() => props.loadClient)
|
||||
|
||||
const debouncedQuery = useDebounce(clientQuery, 300)
|
||||
const alerts = useAlertsStore()
|
||||
|
||||
const { pageNum, offset, firstResult, lastResult, totalPages, nextPage, prevPage, goToPage } =
|
||||
usePagination(totalClients, clientsPerPage)
|
||||
@ -173,12 +185,28 @@ function itemSelected(event: Event) {
|
||||
|
||||
async function createClient(data: ClientCreate) {
|
||||
const response = await SshecretAdmin.createClientApiV1ClientsPost({ body: data })
|
||||
clients.value.unshift(response.data)
|
||||
totalClients.value += 1
|
||||
createDrawerOpen.value = false
|
||||
createFormKey.value += 1
|
||||
treeState.selectClient(response.data.id)
|
||||
router.push({ name: 'Client', params: { id: response.data.id } })
|
||||
try {
|
||||
const responseData = assertSdkResponseOk(response)
|
||||
clients.value.unshift(responseData)
|
||||
totalClients.value += 1
|
||||
createDrawerOpen.value = false
|
||||
createFormKey.value += 1
|
||||
treeState.selectClient(responseData.id)
|
||||
router.push({ name: 'Client', params: { id: responseData.id } })
|
||||
createErrors.value = []
|
||||
} catch (err) {
|
||||
if (err instanceof ValidationError) {
|
||||
createErrors.value = err.errors
|
||||
} else {
|
||||
const errorMessage = err.message ?? 'Unknown error'
|
||||
alerts.showAlert(`Error from backend: ${errorMessage}`, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearCreateErrors() {
|
||||
// Clear any errors from the create form.
|
||||
createErrors.value = []
|
||||
}
|
||||
|
||||
async function clientDeleted(id: string) {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<SecretSkeleton v-if="loading" />
|
||||
<NotFound v-if="notfound" />
|
||||
<SecretDetail
|
||||
:secret="secret"
|
||||
@update="updateSecretValue"
|
||||
@ -8,26 +10,43 @@
|
||||
@removeClient="removeClientSecret"
|
||||
v-if="secret"
|
||||
/>
|
||||
<SecretSkeleton v-else />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import SecretDetail from '@/components/secrets/SecretDetail.vue'
|
||||
import SecretSkeleton from '@/components/secrets/SecretSkeleton.vue'
|
||||
import NotFound from '@/components/common/NotFound.vue'
|
||||
import { ApiError, NotFoundError, ValidationError } from '@/api/errors'
|
||||
import { useTreeState } from '@/store/useTreeState'
|
||||
import { useAlertsStore } from '@/store/useAlertsStore'
|
||||
|
||||
import type { SecretView } from '@/client/types.gen.ts'
|
||||
import { SshecretAdmin } from '@/client'
|
||||
|
||||
const alerts = useAlertsStore()
|
||||
const props = defineProps<{ id: string | null; parentId: string | null }>()
|
||||
const secret = ref<SecretView>()
|
||||
|
||||
const treeState = useTreeState()
|
||||
|
||||
const notfound = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
async function loadSecret() {
|
||||
if (!props.id) return
|
||||
secret.value = await treeState.getSecret(props.id)
|
||||
loading.value = true
|
||||
try {
|
||||
secret.value = await treeState.getSecret(props.id)
|
||||
} catch (err) {
|
||||
if (err instanceof NotFoundError) {
|
||||
notfound.value = true
|
||||
} else {
|
||||
alerts.showAlert(`Error from backend: ${err.message}`, 'error')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSecretValue(value: string) {
|
||||
@ -53,6 +72,8 @@ async function deleteSecret(clients: string[]) {
|
||||
for (const clientId in clients) {
|
||||
await treeState.refreshClient(clientId)
|
||||
}
|
||||
await treeState.getSecretGroups()
|
||||
treeState.bumpGroupRevision()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user