WP-12: Kubernetes overview and workload actions
kube-rs gateway (nodes, namespaces, deployments/statefulsets/daemonsets with images, PVCs; rollout restart, scale, set image via patches) using the microk8s kubeconfig by default, fake gateway for dev, /api/cluster routes, Kubernetes page with node cards, workload table with admin actions and PVC list. Also implements the streaming command runner that WP-11 relied on. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@ -7,6 +7,7 @@ const router = useRouter()
|
||||
const nav = [
|
||||
{ to: '/', label: 'Dashboard' },
|
||||
{ to: '/updates', label: 'Updates' },
|
||||
{ to: '/cluster', label: 'Kubernetes' },
|
||||
{ to: '/vulnerabilities', label: 'Vulnerabilities' },
|
||||
{ to: '/backups', label: 'Backups' },
|
||||
{ to: '/jobs', label: 'Jobs' },
|
||||
|
||||
61
frontend/src/components/WorkloadTable.vue
Normal file
61
frontend/src/components/WorkloadTable.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type { Workload } from '../api/types'
|
||||
|
||||
defineProps<{ workloads: Workload[]; canAct: boolean }>()
|
||||
defineEmits<{ restart: [w: Workload]; scale: [w: Workload]; image: [w: Workload] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th class="py-2">Namespace</th>
|
||||
<th>Name</th>
|
||||
<th>Kind</th>
|
||||
<th>Ready</th>
|
||||
<th>Images</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="w in workloads"
|
||||
:key="`${w.namespace}/${w.kind}/${w.name}`"
|
||||
class="border-b border-gray-100"
|
||||
:class="w.ready < w.desired ? 'bg-red-50' : ''"
|
||||
>
|
||||
<td class="py-1.5">{{ w.namespace }}</td>
|
||||
<td class="font-mono">{{ w.name }}</td>
|
||||
<td>{{ w.kind }}</td>
|
||||
<td :class="w.ready < w.desired ? 'font-medium text-red-700' : ''">
|
||||
{{ w.ready }}/{{ w.desired }}
|
||||
</td>
|
||||
<td class="font-mono text-xs text-gray-600">
|
||||
<div v-for="c in w.containers" :key="c.name">{{ c.image }}</div>
|
||||
</td>
|
||||
<td class="space-x-3 whitespace-nowrap text-right">
|
||||
<template v-if="canAct">
|
||||
<button
|
||||
name="restart"
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="$emit('restart', w)"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
v-if="w.kind !== 'daemonset'"
|
||||
name="scale"
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="$emit('scale', w)"
|
||||
>
|
||||
Scale
|
||||
</button>
|
||||
<button name="image" class="text-blue-600 hover:underline" @click="$emit('image', w)">
|
||||
Set image
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
132
frontend/src/pages/ClusterPage.vue
Normal file
132
frontend/src/pages/ClusterPage.vue
Normal file
@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api, ApiError } from '../api/client'
|
||||
import type { ClusterOverview, Workload } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import WorkloadTable from '../components/WorkloadTable.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
const overview = ref<ClusterOverview | null>(null)
|
||||
const error = ref('')
|
||||
|
||||
async function load() {
|
||||
error.value = ''
|
||||
try {
|
||||
overview.value = await api.get<ClusterOverview>('/api/cluster/overview')
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'Request failed'
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
const path = (w: Workload) => `/api/cluster/workloads/${w.namespace}/${w.kind}/${w.name}`
|
||||
|
||||
async function act(label: string, fn: () => Promise<unknown>) {
|
||||
try {
|
||||
await fn()
|
||||
toast.success(label)
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Request failed')
|
||||
}
|
||||
}
|
||||
|
||||
function restart(w: Workload) {
|
||||
if (!confirm(`Rolling restart of ${w.kind} ${w.namespace}/${w.name}?`)) return
|
||||
act('Restart requested', () => api.post(path(w) + '/restart', {}))
|
||||
}
|
||||
|
||||
function scale(w: Workload) {
|
||||
const v = prompt(`Replicas for ${w.namespace}/${w.name}`, String(w.desired))
|
||||
if (v === null) return
|
||||
act(`Scaled to ${v}`, () => api.post(path(w) + '/scale', { replicas: Number(v) }))
|
||||
}
|
||||
|
||||
function setImage(w: Workload) {
|
||||
const current = w.containers[0]?.image ?? ''
|
||||
const image = prompt(`New image for ${w.namespace}/${w.name} (${w.containers[0]?.name})`, current)
|
||||
if (!image || image === current) return
|
||||
act('Image updated, rollout started', () => api.post(path(w) + '/image', { image }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Kubernetes</h1>
|
||||
<button class="rounded-md border border-gray-300 px-4 py-2 text-sm" @click="load">
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" role="alert" class="mt-6 rounded-md bg-red-50 p-4 text-sm text-red-800">
|
||||
Cluster unreachable: {{ error }}
|
||||
</p>
|
||||
|
||||
<template v-if="overview">
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<div
|
||||
v-for="n in overview.nodes"
|
||||
:key="n.name"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4"
|
||||
>
|
||||
<div class="text-xs uppercase text-gray-500">Node</div>
|
||||
<div class="mt-1 text-lg font-medium">{{ n.name }}</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
<span data-testid="node-version">{{ n.version }}</span> · {{ n.container_runtime }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">{{ n.os_image }} · {{ n.kernel }}</div>
|
||||
<span
|
||||
class="mt-2 inline-block rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="n.ready ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'"
|
||||
>
|
||||
{{ n.ready ? 'Ready' : 'NotReady' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div class="text-xs uppercase text-gray-500">Workloads</div>
|
||||
<div class="mt-1 text-2xl font-semibold">{{ overview.workloads.length }}</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ overview.images.length }} distinct images · {{ overview.namespaces.length }} namespaces
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-8 text-lg font-medium">Workloads</h2>
|
||||
<WorkloadTable
|
||||
class="mt-3"
|
||||
:workloads="overview.workloads"
|
||||
:can-act="auth.isAdmin"
|
||||
@restart="restart"
|
||||
@scale="scale"
|
||||
@image="setImage"
|
||||
/>
|
||||
|
||||
<h2 class="mt-8 text-lg font-medium">Persistent volume claims</h2>
|
||||
<table class="mt-3 w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th class="py-2">Namespace</th>
|
||||
<th>Name</th>
|
||||
<th>Capacity</th>
|
||||
<th>Storage class</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="p in overview.volume_claims"
|
||||
:key="`${p.namespace}/${p.name}`"
|
||||
class="border-b border-gray-100"
|
||||
>
|
||||
<td class="py-1.5">{{ p.namespace }}</td>
|
||||
<td class="font-mono">{{ p.name }}</td>
|
||||
<td>{{ p.capacity }}</td>
|
||||
<td>{{ p.storage_class }}</td>
|
||||
<td>{{ p.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</template>
|
||||
@ -9,6 +9,7 @@ import PlaceholderPage from './pages/PlaceholderPage.vue'
|
||||
import SettingsPage from './pages/SettingsPage.vue'
|
||||
import JobsPage from './pages/JobsPage.vue'
|
||||
import UpdatesPage from './pages/UpdatesPage.vue'
|
||||
import ClusterPage from './pages/ClusterPage.vue'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@ -20,6 +21,7 @@ export const router = createRouter({
|
||||
children: [
|
||||
{ path: '', name: 'dashboard', component: DashboardPage },
|
||||
{ path: 'updates', component: UpdatesPage },
|
||||
{ path: 'cluster', component: ClusterPage },
|
||||
{
|
||||
path: 'vulnerabilities',
|
||||
component: PlaceholderPage,
|
||||
|
||||
Reference in New Issue
Block a user