Skip to content

Commit

Permalink
feat: adds pods to pvc view, make swagger more discoverable (#271)
Browse files Browse the repository at this point in the history
Co-authored-by: Tristan Holaday <40547442+TristanHoladay@users.noreply.github.com>
  • Loading branch information
UncleGedd and TristanHoladay authored Sep 3, 2024
1 parent 9875e62 commit d6e5eba
Show file tree
Hide file tree
Showing 15 changed files with 163 additions and 59 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ vite.config.ts.timestamp-*

# # Playwright/ Vitest
/ui/test-results
screenshot.png

# Dev
zarf-sbom
Expand Down
5 changes: 4 additions & 1 deletion pkg/api/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ func Setup(assets *embed.FS) (*chi.Mux, error) {
return nil, fmt.Errorf("failed to create cache: %w", err)
}

// Add Swagger UI route
// Add Swagger UI routes
r.Get("/swagger", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/swagger/index.html", http.StatusMovedPermanently)
})
r.Get("/swagger/*", httpSwagger.WrapHandler)
// expose API_AUTH_DISABLED env var to frontend via endpoint
r.Get("/auth-status", serveAuthStatus)
Expand Down
8 changes: 7 additions & 1 deletion tasks/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,21 @@ tasks:
- name: deploy-min-core
description: install min resources for UDS Core
actions:
# todo: refactor this with UDS functional layers: https://github.com/defenseunicorns/uds-runtime/issues/172
- cmd: rm -fr tmp && git clone --depth=1 https://github.com/defenseunicorns/uds-core.git tmp/uds-core
description: clone UDS Core
- cmd: npm ci && npx pepr deploy --confirm
description: deploy UDS Core's Pepr module
dir: tmp/uds-core
- cmd: |
helm repo add istio https://istio-release.storage.googleapis.com/charts
helm install istio-base istio/base -n istio-system --set defaultRevision=default --create-namespace
helm upgrade -i istio-base istio/base -n istio-system --set defaultRevision=default --create-namespace
description: install Istio CRDs
- cmd: |
helm repo add minio https://charts.min.io/
helm upgrade -i -n uds-dev-stack minio minio/minio --create-namespace --set replicas=1 --set mode=standalone --set persistence.size=1Gi \
--set resources.requests.memory=256Mi --set resources.requests.cpu=100m
description: deploy minio to test PVCs
- name: k3d
description: "start a k3d cluster"
Expand Down
28 changes: 20 additions & 8 deletions ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@types/date-fns": "^2.6.0",
"@types/dompurify": "3.0.5",
"@types/eslint": "9.6.0",
"@types/lodash": "^4.17.7",
"ansi-to-html": "0.7.2",
"apexcharts": "3.51.0",
"autoprefixer": "10.4.19",
Expand All @@ -40,11 +41,12 @@
"globals": "15.8.0",
"highlight.js": "11.10.0",
"jsdom": "25.0.0",
"lodash": "^4.17.21",
"prettier": "3.3.3",
"prettier-plugin-organize-imports": "4.0.0",
"prettier-plugin-svelte": "3.2.6",
"prettier-plugin-tailwindcss": "0.6.5",
"svelte": "4.2.18",
"svelte": "4.2.19",
"svelte-check": "3.8.4",
"tailwindcss": "3.4.6",
"tslib": "2.6.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
['namespace'],
['storage_class'],
['capacity'],
['pods'],
['age'],
['status'],
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import '@testing-library/jest-dom'

import {
expectEqualIgnoringFields,
MockEventSource,
MockResourceStore,
testK8sTableWithCustomColumns,
testK8sTableWithDefaults,
} from '$features/k8s/test-helper'
import type { ResourceWithTable } from '$features/k8s/types'
import { resourceDescriptions } from '$lib/utils/descriptions'
import type { V1PersistentVolumeClaim } from '@kubernetes/client-node'
import { vi } from 'vitest'
import Component from './component.svelte'
import { createStore } from './store'

Expand All @@ -26,13 +28,25 @@ suite('PersistentVolumeClaim Component', () => {

testK8sTableWithDefaults(Component, {
createStore,
columns: [['name', 'emphasize'], ['namespace'], ['storage_class'], ['capacity'], ['age'], ['status']],
columns: [['name', 'emphasize'], ['namespace'], ['storage_class'], ['capacity'], ['pods'], ['age'], ['status']],
name,
description,
})

testK8sTableWithCustomColumns(Component, { createStore, name, description })

const urlAssertionMock = vi.fn().mockImplementation((url: string) => {
console.log(url)
})

vi.stubGlobal(
'EventSource',
vi
.fn()
// pods EventSource is created first in createStore()
.mockImplementationOnce((url: string) => new MockEventSource(url, urlAssertionMock)),
)

vi.mock('../../store.ts', async (importOriginal) => {
const mockData = [
{
Expand Down Expand Up @@ -67,13 +81,15 @@ suite('PersistentVolumeClaim Component', () => {
namespace: 'loki',
storage_class: 'local-path',
capacity: '10Gi',
status: 'Bound',
status: { component: Component, props: { status: 'Bound' } },
},
]

const store = createStore()
const start = store.start as unknown as () => ResourceWithTable<V1PersistentVolumeClaim, any>[]
expect(store.url).toEqual(`/api/v1/resources/storage/persistentvolumeclaims?dense=true`)
// ignore creationTimestamp because age is not calculated at this point and added to the table
expectEqualIgnoringFields(start()[0].table, expectedTables[0] as unknown, ['creationTimestamp'])

// ignore creationTimestamp and pods because neither are calculated at this point and added to the table
expectEqualIgnoringFields(start()[0].table, expectedTables[0] as unknown, ['creationTimestamp', 'status.component'])
vi.unstubAllGlobals()
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!-- SPDX-License-Identifier: Apache-2.0 -->
<!-- SPDX-FileCopyrightText: 2024-Present The UDS Authors -->

<script lang="ts">
import { type PVCStatus } from '$features/k8s/types'
import { getColorAndStatus } from '$lib/features/k8s/helpers'
export let status: PVCStatus
$: statusClass = getColorAndStatus('PersistentVolumeClaims', status)
</script>

{#if status}
<span class={statusClass}>
{status}
</span>
{:else}
-
{/if}
61 changes: 55 additions & 6 deletions ui/src/lib/features/k8s/storage/persistentvolumeclaims/store.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,84 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2024-Present The UDS Authors

import type { V1PersistentVolumeClaim as Resource } from '@kubernetes/client-node'
import type { V1PersistentVolumeClaim as Resource, V1Pod } from '@kubernetes/client-node'

import { apiAuthEnabled } from '$features/api-auth/store'
import { ResourceStore, transformResource } from '$features/k8s/store'
import { type ColumnWrapper, type CommonRow, type ResourceStoreInterface } from '$features/k8s/types'
import { get, writable } from 'svelte/store'
import Status from './status/component.svelte'

interface Row extends CommonRow {
storage_class: string
capacity: string
pods: string[]
status: string
status: { component: typeof Status; props: { status: string } }
}

export type Columns = ColumnWrapper<Row>

export function createStore(): ResourceStoreInterface<Resource, Row> {
const url = `/api/v1/resources/storage/persistentvolumeclaims?dense=true`

// correlate pods with pvcs
const pods = new Map<string, string[]>() // map of pvc name -> pod names
const podStore = writable<number>()
const jsonPathFields = 'metadata.name,spec.volumes,status.phase'
let podEvents: EventSource
const podEventsPath = `/api/v1/resources/workloads/pods?fields=${jsonPathFields}`

if (get(apiAuthEnabled)) {
const apiToken: string = sessionStorage.getItem('token') ?? ''
podEvents = new EventSource(`${podEventsPath}&token=${apiToken}`)
} else {
podEvents = new EventSource(podEventsPath)
}

podEvents.onmessage = (event) => {
const data = JSON.parse(event.data) as V1Pod[]
data.forEach((p) => {
// find the pvcs for each pod
p.spec?.volumes?.forEach((v) => {
const claimName = `${v.persistentVolumeClaim?.claimName}` || ''
let podNames = pods.get(claimName) ?? []
if (claimName && p.status?.phase === 'Running') {
// add pod to state
podNames.push(p.metadata?.name ?? '')
podNames = Array.from(new Set(podNames)) // de-dup
pods.set(claimName, podNames)
} else if (claimName && p.status?.phase !== 'Running') {
// remove terminated pods from state
podNames = podNames.filter((n) => n !== p.metadata?.name)
pods.set(claimName, podNames)
}
})
})

// trigger an update
podStore.set(event.timeStamp)
}

const transform = transformResource<Resource, Row>((r) => ({
storage_class: r.spec?.storageClassName ?? '',
capacity: r.spec?.resources?.requests?.storage ?? '',
status: r.status?.phase ?? '',
status: { component: Status, props: { status: r.status?.phase ?? '' } },
}))

const store = new ResourceStore<Resource, Row>(url, transform, 'name')

const store = new ResourceStore<Resource, Row>(url, transform, 'name', true, [podStore])
store.stopCallback = podEvents.close.bind(podEvents)
store.filterCallback = (data) => {
return data.map((d) => {
const pvcName = d.table.name
if (pods.has(pvcName)) {
d.table.pods = pods.get(pvcName) ?? []
}
return d
})
}
return {
...store,
start: store.start.bind(store),

sortByKey: store.sortByKey.bind(store),
}
}
12 changes: 11 additions & 1 deletion ui/src/lib/features/k8s/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,15 @@ export class ResourceStore<T extends KubernetesObject, U extends CommonRow> impl
* @param tableCallback The callback to create the table from the resources
* @param sortBy The initial key to sort the table by
* @param sortAsc The initial sort direction
* @param additionalStores
*/
constructor(url: string, tableCallback: (data: T[]) => ResourceWithTable<T, U>[], sortBy: keyof U, sortAsc = true) {
constructor(
url: string,
tableCallback: (data: T[]) => ResourceWithTable<T, U>[],
sortBy: keyof U,
sortAsc = true,
additionalStores: Writable<unknown>[] = [],
) {
this.url = url
this.#tableCallback = tableCallback

Expand All @@ -66,6 +73,9 @@ export class ResourceStore<T extends KubernetesObject, U extends CommonRow> impl
this.namespace = writable<string>('')
this.numResources = writable<number>(0)

// assign additional stores (expected to be init'd before constructor)
this.additionalStores = additionalStores

// Create a derived store that combines all the filtering and sorting logic
const filteredAndSortedResources = derived(
[
Expand Down
36 changes: 9 additions & 27 deletions ui/src/lib/features/k8s/test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { render } from '@testing-library/svelte'

import * as components from '$components'
import type { KubernetesObject } from '@kubernetes/client-node'
import _ from 'lodash'
import type { ComponentType } from 'svelte'
import type { Mock } from 'vitest'
import type { CommonRow, ResourceWithTable } from './types'
Expand Down Expand Up @@ -53,35 +54,16 @@ export function testK8sTableWithCustomColumns(Component: ComponentType, props: R
})
}

// TODO: look into deep copies since nested objects are still references and are getting mutated
// Helper function to compare two objects while ignoring certain fields; can ignore nested fields (eg 'metadata.creationTimestamp')
export function expectEqualIgnoringFields<T>(actual: T, expected: T, fieldsToIgnore: string[]) {
const expectedWithoutFields = { ...expected }
const actualWithoutFields = { ...actual }

fieldsToIgnore.forEach((field) => {
if (field.includes('.')) {
const paths = field.split('.')

// Create temporary objects to traverse the object and delete the last field
let tmpExpect = expectedWithoutFields
let tmpActual = actualWithoutFields
// Traverse the object to the second to last field (e.g. [field1, field2, field3] -> field2)
for (let i = 0; i <= paths.length - 2; i++) {
tmpExpect = tmpExpect[paths[i] as keyof typeof tmpExpect] as T
tmpActual = tmpActual[paths[i] as keyof typeof tmpActual] as T

// when second to last field reached (e.g. field2 of 3), delete the last field (e.g. delete {field3: value})
if (i === paths.length - 2) {
delete tmpExpect[paths[i + 1] as keyof typeof tmpExpect]
delete tmpActual[paths[i + 1] as keyof typeof tmpActual]
}
}
} else {
delete expectedWithoutFields[field as keyof typeof expectedWithoutFields]
delete actualWithoutFields[field as keyof typeof actualWithoutFields]
}
})
const removeFields = (obj: T, fields: string[]) => {
const result = _.cloneDeep(obj)
fields.forEach((field) => _.unset(result, field))
return result
}

const expectedWithoutFields = removeFields(expected, fieldsToIgnore)
const actualWithoutFields = removeFields(actual, fieldsToIgnore)

expect(actualWithoutFields).toEqual(expectedWithoutFields)
}
Expand Down
Loading

0 comments on commit d6e5eba

Please sign in to comment.