💄 Improve UI

This commit is contained in:
andreas.dinauer 2025-10-26 18:56:20 +01:00
parent a8050fa958
commit 1a1dd92808
38 changed files with 1029 additions and 65 deletions

View File

@ -133,7 +133,7 @@
} }
.tile, *[class^='tile-'], *[class*=' tile-'] { .tile, *[class^='tile-'], *[class*=' tile-'] {
background-color: #ebebeb; background-color: var(--tile-color);
border-radius: 0.25rem; border-radius: 0.25rem;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;

View File

@ -6,10 +6,16 @@
margin: 0; margin: 0;
font-weight: 600; font-weight: 600;
font-family: "Source Code Pro", monospace; font-family: "Source Code Pro", monospace;
text-decoration: none;
} }
html { html {
--primary-color: rgb(87, 75, 255); --primary-color: rgb(87, 75, 255);
background-color: #f1f1f1;
--tile-color: rgb(226, 226, 226);
}
html * {
color: rgb(31, 31, 31);
} }
html, body, #__nuxt { html, body, #__nuxt {
@ -21,14 +27,14 @@ html, body, #__nuxt {
display: grid; display: grid;
grid-template-columns: auto auto auto 1fr auto auto; grid-template-columns: auto auto auto 1fr auto auto;
align-content: start; align-content: start;
background-color: #ebebeb; background-color: rgb(228, 228, 228);
} }
.resource, .header { .resource, .header {
display: contents; display: contents;
} }
.header > * { .header > * {
padding: 0.75rem; padding: 0.75rem;
background-color: rgb(29, 29, 29); background-color: rgb(12, 12, 12);
color: white; color: white;
font-weight: bold; font-weight: bold;
position: sticky; position: sticky;
@ -44,5 +50,5 @@ html, body, #__nuxt {
font-family: "Source Code Pro", monospace; font-family: "Source Code Pro", monospace;
} }
.even > .grid-element { .even > .grid-element {
background-color: rgb(216, 216, 216); background-color: rgb(202, 202, 202);
} }

View File

@ -2,14 +2,20 @@ import type { Metadata } from "./Metadata";
export class NodeStats export class NodeStats
{ {
node?: Node; constructor (
public node: Node,
) { }
relativeCpuUsage?: number; relativeCpuUsage?: number;
relativeMemory?: number; relativeMemory?: number;
} }
class Node export class Node
{ {
metadata?: Metadata; constructor (
public metadata: Metadata
) { }
status?: Status; status?: Status;
} }

View File

@ -4,9 +4,12 @@ import type { Metadata } from "./Metadata";
export class Pod export class Pod
{ {
metadata?: Metadata
status?: Status status?: Status
spec?: Spec spec?: Spec
constructor (
public metadata: Metadata
) { }
} }
class Spec { class Spec {

12
classes/Threshold.ts Normal file
View File

@ -0,0 +1,12 @@
export class Threshold
{
constructor (
public value: number,
public calc: (value: number) => State
) { }
}
export enum State
{
GREEN = "GREEN", ORANGE = "ORANGE", RED = "RED"
}

31
classes/Ticker.ts Normal file
View File

@ -0,0 +1,31 @@
export function useTicker(interval: string)
{
const seconds = Duration.parse(interval);
const ticker = ref(new Date());
setInterval(() => {
ticker.value = new Date();
}, seconds * 1000);
return ticker;
}
export class Duration
{
static parse(input: string): number {
if (input.length > 1) {
const unit = input.charAt(input.length - 1);
const value = parseInt(input.substring(0, input.length - 1), 10);
switch (unit)
{
case 's':
return value;
case 'm':
return value * 60;
case 'h':
return value * 60 * 60;
default:
throw new Error(`Invalid unit ${unit}`);
}
}
throw new Error("Invalid input");
}
}

View File

@ -1,13 +1,13 @@
<template> <template>
<nav class="sidebar"> <SidebarTemplate>
<div class="content-l"> <div class="content-l">
<h2>Kubooboo</h2> <h2>Kubooboo</h2>
<div class="nav"> <div class="nav">
<NuxtLink class="resources" to="/dashboard/nodes">Nodes</NuxtLink> <NuxtLink class="resources" to="/account/inspect/nodes">Nodes</NuxtLink>
<NuxtLink class="resources" to="/dashboard/ingresses">Ingresses</NuxtLink> <NuxtLink class="resources" to="/account/inspect/ingresses">Ingresses</NuxtLink>
<NuxtLink class="resources" to="/dashboard/services">Services</NuxtLink> <NuxtLink class="resources" to="/account/inspect/services">Services</NuxtLink>
<NuxtLink class="resources" to="/dashboard/deployments">Deployments</NuxtLink> <NuxtLink class="resources" to="/account/inspect/deployments">Deployments</NuxtLink>
<NuxtLink class="resources" to="/dashboard/pods">Pods</NuxtLink> <NuxtLink class="resources" to="/account/inspect/pods">Pods</NuxtLink>
</div> </div>
<div class="divider" :class="{ hide: !inNamespaceScopedResource }"></div> <div class="divider" :class="{ hide: !inNamespaceScopedResource }"></div>
<div :class="{ hide: !inNamespaceScopedResource }"> <div :class="{ hide: !inNamespaceScopedResource }">
@ -23,11 +23,12 @@
<UiIcon>account_circle</UiIcon> <UiIcon>account_circle</UiIcon>
<p>{{ user.username }}</p> <p>{{ user.username }}</p>
</div> </div>
</nav> </SidebarTemplate>
<AccountPopup ref="accountPopup"></AccountPopup>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SidebarTemplate from './SidebarTemplate.vue';
import { useNamespaceStore } from '#imports'; import { useNamespaceStore } from '#imports';
const namespaceStore = useNamespaceStore(); const namespaceStore = useNamespaceStore();
@ -41,46 +42,10 @@ const user = getUser();
const accountPopup = ref(); const accountPopup = ref();
const inNamespaceScopedResource: ComputedRef<boolean> = computed(() => { const inNamespaceScopedResource: ComputedRef<boolean> = computed(() => {
if(useRoute().fullPath.startsWith('/dashboard/nodes')) if(useRoute().fullPath.startsWith('/account/inspect/nodes'))
{ {
return false; return false;
} }
return true; return true;
}); });
</script> </script>
<style scoped>
.sidebar {
padding: 0.75rem;
background-color: rgb(29, 29, 29);
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 1rem;
height: 100%;
}
.sidebar * {
color: white;
}
.namespace, .resources {
padding: 0.5rem;
border-radius: 0.25rem;
}
.namespace.active, .resources.router-link-active {
background-color: var(--primary-color)
}
.namespace.active *, .resources.router-link-active {
color: white;
}
.divider {
height: 1px;
background-color: lightgray;
}
.nav > * {
display: block;
text-decoration: none;
}
.hide {
visibility: hidden;
}
</style>

View File

@ -45,6 +45,7 @@ defineExpose({
white-space: pre-wrap; white-space: pre-wrap;
color: white; color: white;
padding: 0.25rem 1rem; padding: 0.25rem 1rem;
line-height: 1.0.5rem;
} }
.console { .console {
background-color: black; background-color: black;

View File

@ -0,0 +1,74 @@
<template>
<UiInput class="timeframe">
<select name="" id="" v-model="timeframe">
<option :value="Timeframe.LAST_HOUR">Letzte Stunde</option>
<option :value="Timeframe.LAST_3_HOURS">Letzte 3 Stunden</option>
<option :value="Timeframe.LAST_6_HOURS">Letzte 6 Stunden</option>
<option :value="Timeframe.LAST_12_HOURS">Letzte 12 Stunden</option>
<option :value="Timeframe.LAST_24_HOURS">Letzte 24 Stunden</option>
<option :value="Timeframe.LAST_7_DAYS">Letzte 7 Tage</option>
<option :value="Timeframe.LAST_30_DAYS">Letzte 30 Tage</option>
<option :value="Timeframe.LAST_90_DAYS">Letzte 90 Tage</option>
<option :value="undefined">Custom...</option>
</select>
</UiInput>
</template>
<script setup lang="ts">
import dayjs, { Dayjs } from 'dayjs';
import utc from 'dayjs/plugin/utc';
const enum Timeframe
{
LAST_HOUR = "LAST_HOUR",
LAST_3_HOURS = "LAST_3_HOURS",
LAST_6_HOURS = "LAST_6_HOURS",
LAST_12_HOURS = "LAST_12_HOURS",
TODAY = "TODAY",
LAST_24_HOURS = "LAST_24_HOURS",
LAST_7_DAYS = "LAST_7_DAYS",
LAST_30_DAYS = "LAST_30_DAYS",
LAST_90_DAYS = "LAST_90_DAYS"
}
const timeframe: Ref<string | undefined> = ref(Timeframe.LAST_HOUR);
const emits = defineEmits<{
(e: 'update:modelValue', value?: Date): void
}>();
const startTime = computed(() => {
dayjs.extend(utc)
switch (timeframe.value)
{
case Timeframe.LAST_HOUR: {
return dayjs().utc().subtract(1, 'hours');
}
case Timeframe.LAST_3_HOURS: {
return dayjs().utc().subtract(3, 'hours');
}
case Timeframe.LAST_6_HOURS: {
return dayjs().utc().subtract(6, 'hours');
}
case Timeframe.LAST_12_HOURS: {
return dayjs().utc().subtract(12, 'hours');
}
case Timeframe.LAST_24_HOURS: {
return dayjs().utc().subtract(24, 'hours');
}
case Timeframe.LAST_7_DAYS: {
return dayjs().utc().subtract(7, 'days');
}
case Timeframe.LAST_30_DAYS: {
return dayjs().utc().subtract(30, 'days');
}
case Timeframe.LAST_90_DAYS: {
return dayjs().utc().subtract(90, 'days');
}
}
})
watch(startTime, (startTime) => {
emits('update:modelValue', startTime?.toDate());
}, { immediate: true });
</script>

View File

@ -55,15 +55,15 @@ function enableScrolling()
<style scoped> <style scoped>
.overlay { .overlay {
background-color: rgba(0, 0, 0, 0.514); background-color: rgba(223, 223, 223, 0.514);
backdrop-filter: blur(0.1rem); backdrop-filter: blur(0.1rem);
position: fixed; position: fixed;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
padding: 1rem; padding: 0.5rem;
padding: 2rem; padding: 0.5rem;
z-index: 2; z-index: 2;
} }
@ -74,7 +74,8 @@ function enableScrolling()
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 1rem; padding: 1rem;
background-color: white; background-color: rgb(255, 255, 255);
border-radius: 0.5rem;
} }
.popup__header { .popup__header {

65
components/Pulse.vue Normal file
View File

@ -0,0 +1,65 @@
<template>
<div class="outer center" :class="threshold.calc(threshold.value).toString().toLocaleLowerCase()">
<div class="inner">
&nbsp;
</div>
</div>
</template>
<script setup lang="ts">
import type { Threshold } from '~/classes/Threshold';
defineProps<{
threshold: Threshold
}>();
</script>
<style scoped>
.outer {
width: 1.25rem;
height: 1.25rem;
border-radius: 0.75rem;
user-select: none;
}
.inner {
width: 0.75rem;
height: 0.75rem;
border-radius: 0.5rem;
animation: pulse 2s infinite;
}
.outer.green {
background-color: rgb(139, 255, 139);
}
.outer.green .inner {
background-color: green;
}
.outer.orange {
background-color: rgb(255, 192, 151);
}
.outer.orange .inner {
background-color: rgb(255, 132, 31);
}
.outer.red {
background-color: rgb(255, 130, 130);
}
.outer.red .inner {
background-color: rgb(219, 12, 12);
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.5;
}
100% {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<nav class="sidebar">
<slot></slot>
</nav>
<AccountPopup ref="accountPopup"></AccountPopup>
</template>
<script setup lang="ts">
</script>
<style>
.sidebar {
padding: 1rem;
background-color: var(--tile-color);
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.5rem;
height: 100%;
}
.namespace, .resources {
padding: 0.35rem 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
}
.namespace.active, .resources.router-link-active {
background-color: var(--primary-color)
}
.namespace.active *, .resources.router-link-active {
color: white;
}
.divider {
height: 1px;
background-color: rgb(36, 36, 36);
}
.nav > * {
display: block;
text-decoration: none;
}
.hide {
visibility: hidden;
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<div :id="id" class="chart">
</div>
</template>
<script setup lang="ts">
import type { Dataset } from './Dataset';
import { TimeScale, LinearScale, LineController, PointElement, LineElement, Chart, type ChartConfiguration } from 'chart.js';
import 'chartjs-adapter-moment';
Chart.register(TimeScale)
Chart.register(LinearScale)
Chart.register(PointElement)
Chart.register(LineController)
Chart.register(LineElement)
const id = Math.random().toString().replaceAll(".", "");
const props = defineProps<{
datasets: Dataset,
config: any
}>();
onMounted(() => {
watch(props, () => {
renderChart();
}, { immediate: true })
})
function renderChart()
{
const wrapper = document.getElementById(id);
if (wrapper != null)
{
wrapper.replaceChildren();
let data = {
labels: [] as string[],
datasets: [{
data: [] as number[],
borderColor: '#574BFF',
backgroundColor: 'rgba(0, 0, 255, 0.2)', // area fill color
fill: 'origin',
pointRadius: 1,
borderWidth: 2,
tension: 0.3
}]
}
for (const dataset of props.datasets.data)
{
data.labels.push(new Date(dataset.label).toISOString());
data.datasets.at(0)!.data.push(dataset.value);
}
const canvas = document.createElement('canvas');
new Chart(canvas, getConfig(data, props.datasets.unit));
wrapper.appendChild(canvas)
}
}
function getConfig(data: any, unit: string): ChartConfiguration {
const c = props.config;
c.data = data;
c.options.scales.y.ticks.callback = function(value: number) {
return value + " " + unit;
}
return c;
}
</script>

View File

@ -0,0 +1,24 @@
export class Dataset
{
constructor (
public unit: string,
public data: Data[]
) { }
getLatest()
{
if (this.data.length > 0)
{
return this.data.toSorted((lower, higher) => new Date(higher.label).getTime() - new Date(lower.label).getTime()).at(this.data.length - 1);
}
return undefined;
}
}
export class Data
{
constructor (
public value: number,
public label: Date
) { }
}

View File

@ -0,0 +1,66 @@
import axios from "axios";
import type { Node } from "~/classes/Node";
import type { Pod } from "~/classes/Pod";
import { Data } from "../chart/Dataset";
import dayjs from "dayjs";
export class MonitoredResource<T>
{
constructor (
public resource: T,
public jobs: IndexCollection[]
) { }
static getMonitorings(monitoringId: string, onSuccess: (monitoredPod: MonitoredResource<Pod>[]) => void)
{
axios.get<MonitoredResource<Pod>[]>(useRuntimeConfig().public.apiBase + '/monitorings/' + monitoringId + '/jobs')
.then(response => {
onSuccess(response.data)
});
}
static getNodeMonitorings(from: Date, to: Date, onSuccess: (monitoredPod: MonitoredResource<Node>[]) => void)
{
axios.get<MonitoredResource<Node>[]>(useRuntimeConfig().public.apiBase + '/monitorings/nodes/jobs', {
params: {
from: from,
to: to
}
})
.then(response => {
onSuccess(response.data)
});
}
static extractDataset(key: string, jobs: IndexCollection[]): Data[]
{
const data = [] as Data[];
for (const job of jobs)
{
const metrics = job.metrics[key];
if (metrics != null)
{
data.push(new Data(metrics.average, dayjs.utc(job.timestamp).local().toDate()));
}
}
console.log(data);
return data;
}
}
export class IndexCollection
{
constructor (
public metrics: Record<string, Metric>,
public timestamp: Date
) { }
}
export class Metric
{
constructor (
public count: number,
public sum: number,
public average: number
) { }
}

View File

@ -0,0 +1,26 @@
import axios from "axios";
export class VolumeMonitoringConfig
{
constructor (
public id: string,
public configName: string,
public type: string,
public volumeConfig: VolumeConfig
) {}
static get(onSuccess: (monitorings: VolumeMonitoringConfig[]) => void)
{
axios.get<VolumeMonitoringConfig[]>(useRuntimeConfig().public.apiBase + '/monitorings')
.then(response => {
onSuccess(response.data)
});
}
}
class VolumeConfig
{
constructor (
public mountPath: string
) {}
}

View File

@ -0,0 +1,92 @@
<template>
<div class="content-l">
<div class="spaced-center">
<h1>Nodes</h1>
<PeriodPicker v-model="startDate"></PeriodPicker>
</div>
<div class="content-l">
<h2>CPU</h2>
<div class="col-3">
<div v-for="node in nodes">
<div class="tile-m content-m" v-for="dataset in [new Dataset('%', MonitoredResource.extractDataset('RELATIVE_CPU', node.jobs))]">
<div class="left-center">
<Pulse :threshold="new Threshold(dataset.getLatest()!.value, calcCpuThreshold)"></Pulse>
<h3>{{ node.resource.metadata.name }}</h3>
</div>
<div>
<Chart :datasets="dataset" :config="VOLUME_CHART_CONFIG(startDate, endDate)"></Chart>
</div>
</div>
</div>
</div>
</div>
<div class="content-l">
<h2>Memory</h2>
<div class="col-3">
<div v-for="node in nodes">
<div class="tile-m content-m" v-for="dataset in [new Dataset('%', MonitoredResource.extractDataset('RELATIVE_MEMORY', node.jobs))]">
<div class="left-center">
<Pulse :threshold="new Threshold(dataset.getLatest()!.value, calcMemoryThreshold)"></Pulse>
<h3>{{ node.resource.metadata.name }}</h3>
</div>
<div>
<Chart :datasets="dataset" :config="VOLUME_CHART_CONFIG(startDate, endDate)"></Chart>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Node } from '~/classes/Node';
import { MonitoredResource } from './MonitoredResource';
import { Dataset } from '../chart/Dataset';
import { VOLUME_CHART_CONFIG } from './volumes/ChartConfig';
import Pulse from '../Pulse.vue';
import PeriodPicker from '../PeriodPicker.vue';
import { useTicker } from '~/classes/Ticker';
import { State, Threshold } from '~/classes/Threshold';
const nodes: Ref<MonitoredResource<Node>[] | undefined> = ref(undefined);
const startDate = ref();
const endDate = useTicker("10s");
function calcCpuThreshold(value: number)
{
if (value > 85)
{
return State.RED;
}
if (value > 60)
{
return State.ORANGE;
}
return State.GREEN;
}
function calcMemoryThreshold(value: number)
{
if (value > 90)
{
return State.RED;
}
if (value > 70)
{
return State.ORANGE;
}
return State.GREEN;
}
watch([startDate, endDate], ([startDate, endDate]) => {
if (startDate != null && endDate != null)
{
MonitoredResource.getNodeMonitorings(startDate, endDate, (_nodes: MonitoredResource<Node>[]) => {
nodes.value = _nodes;
});
}
}, { immediate: true })
</script>

View File

@ -0,0 +1,102 @@
<template>
<div class="padding-xl content-l">
<h1>Monitorings</h1>
<div class="left-center">
<UiInput class="timeframe">
<select name="" id="" v-model="timeframe">
<option :value="Timeframe.LAST_HOUR">Letzte Stunde</option>
<option :value="Timeframe.LAST_3_HOURS">Letzte 3 Stunden</option>
<option :value="Timeframe.LAST_6_HOURS">Letzte 6 Stunden</option>
<option :value="Timeframe.LAST_12_HOURS">Letzte 12 Stunden</option>
<option :value="Timeframe.LAST_24_HOURS">Letzte 24 Stunden</option>
<option :value="Timeframe.LAST_7_DAYS">Letzte 7 Tage</option>
<option :value="Timeframe.LAST_30_DAYS">Letzte 30 Tage</option>
<option :value="Timeframe.LAST_90_DAYS">Letzte 90 Tage</option>
<option :value="undefined">Custom...</option>
</select>
</UiInput>
<div class="left-center" v-if="timeframe == null">
<UiInput>
<input type="datetime-local">
</UiInput>
<p>bis</p>
<UiInput>
<input type="datetime-local">
</UiInput>
</div>
</div>
<div v-for="monitoring in monitorings">
<VolumeMonitoringConfigComponent :monitoring="monitoring" v-if="monitoring.type === 'VOLUME'"></VolumeMonitoringConfigComponent>
<MemoryMonitoringConfigComponent :monitoring="monitoring" v-if="monitoring.type === 'MEMORY'"></MemoryMonitoringConfigComponent>
</div>
</div>
</template>
<script setup lang="ts">
import 'chartjs-adapter-luxon';
import { VolumeMonitoringConfig } from './MonitoringConfig';
import VolumeMonitoringConfigComponent from './volumes/VolumeMonitoringConfigComponent.vue';
import MemoryMonitoringConfigComponent from './memory/MemoryMonitoringConfigComponent.vue';
import utc from 'dayjs/plugin/utc';
import dayjs from 'dayjs';
const enum Timeframe
{
LAST_HOUR = "LAST_HOUR",
LAST_3_HOURS = "LAST_3_HOURS",
LAST_6_HOURS = "LAST_6_HOURS",
LAST_12_HOURS = "LAST_12_HOURS",
TODAY = "TODAY",
LAST_24_HOURS = "LAST_24_HOURS",
LAST_7_DAYS = "LAST_7_DAYS",
LAST_30_DAYS = "LAST_30_DAYS",
LAST_90_DAYS = "LAST_90_DAYS"
}
const monitorings: Ref<VolumeMonitoringConfig[] | undefined> = ref(undefined);
const timeframe: Ref<string | undefined> = ref("LAST_HOUR");
onMounted(() => {
VolumeMonitoringConfig.get((_monitorings) => {
monitorings.value = _monitorings;
});
});
const startTime = computed(() => {
dayjs.extend(utc)
switch (timeframe.value)
{
case Timeframe.LAST_HOUR: {
return dayjs().utc().subtract(1, 'hours');
}
case Timeframe.LAST_3_HOURS: {
return dayjs().utc().subtract(3, 'hours');
}
case Timeframe.LAST_6_HOURS: {
return dayjs().utc().subtract(6, 'hours');
}
case Timeframe.LAST_12_HOURS: {
return dayjs().utc().subtract(12, 'hours');
}
case Timeframe.LAST_24_HOURS: {
return dayjs().utc().subtract(24, 'hours');
}
case Timeframe.LAST_7_DAYS: {
return dayjs().utc().subtract(7, 'days');
}
case Timeframe.LAST_30_DAYS: {
return dayjs().utc().subtract(30, 'days');
}
case Timeframe.LAST_90_DAYS: {
return dayjs().utc().subtract(90, 'days');
}
}
})
</script>
<style scoped>
.timeframe {
width: 13rem;
}
</style>

View File

@ -0,0 +1,52 @@
import { elements } from "chart.js";
import dayjs from "dayjs"
export function MEMORY_CHART_CONFIG() {
return {
type: 'line',
options: {
plugins: {
title: {
text: 'Chart.js Time Scale',
display: true
}
},
aspectRatio: 2.75,
scales: {
x: {
type: 'time',
time: {
// Luxon format string
tooltipFormat: 'DD.MM'
},
ticks: {
autoSkip: true,
maxTicksLimit: 6,
align: 'center',
color: '#cacacaff',
callback: function (value: Date) {
return dayjs(value).format("HH:mm");
}
},
grid: {
color: '#cacacaff',
borderColor: '#cacacaff',
lineWidth: 1
},
},
y: {
beginAtZero: true,
ticks: {
maxTicksLimit: 6,
color: '#cacacaff'
},
grid: {
color: '#cacacaff',
borderColor: '#cacacaff',
lineWidth: 1
}
}
}
}
}
};

View File

@ -0,0 +1,54 @@
<template>
<div class="content-l">
<p>{{ pod.pod.metadata.namespace }} / {{ pod.pod.metadata.name }}</p>
<Chart :datasets="dataset" :config="MEMORY_CHART_CONFIG()"></Chart>
</div>
</template>
<script setup lang="ts">
import { Data, Dataset } from '~/components/chart/Dataset';
import { MonitoredPod } from '../MonitoredResource';
import { MEMORY_CHART_CONFIG } from './ChartConfig';
const units = new Map<number, string>([[0, "B"], [1, "Ki"], [2, "Mi"], [3, "Gi"], [4, "Ti"]]);
const props = defineProps<{
pod: MonitoredPod
}>();
const dataset = computed(() => {
const result = [] as Data[];
const max = Math.max(...props.pod.jobs.map(job => job.average));
const dimension = getDimension(max);
for (const job of props.pod.jobs)
{
result.push(new Data(job.average / (Math.pow(1024, dimension)), job.timestamp));
}
return new Dataset(units.get(dimension)!, result);
});
function getDimension(max: number)
{
if (max <= 1024)
{
return 0;
}
else if (max <= (1024 * 1024))
{
return 1;
}
else if (max <= (1024 * 1024 * 1024))
{
return 2;
}
else if (max <= (1024 * 1024 * 1024 * 1024))
{
return 3;
}
else if (max <= (1024 * 1024 * 1024 * 1024 * 1024))
{
return 4;
}
return -1;
}
</script>

View File

@ -0,0 +1,32 @@
<template>
<div class="content-l">
<h2>{{ createLabel(monitoring.type) }} Monitoring: {{ monitoring.configName }}</h2>
<div class="col-3">
<MemoryMonitoredPodComponent class="tile-l" v-for="pod in pods" :pod="pod"></MemoryMonitoredPodComponent>
</div>
</div>
</template>
<script setup lang="ts">
import type { Pod } from '~/classes/Pod';
import { MonitoredResource } from '../MonitoredResource';
import { VolumeMonitoringConfig } from '../MonitoringConfig';
import MemoryMonitoredPodComponent from './MemoryMonitoredPodComponent.vue';
const props = defineProps<{
monitoring: VolumeMonitoringConfig
}>();
const pods: Ref<MonitoredResource<Pod>[] | undefined> = ref(undefined);
onMounted(() => {
MonitoredResource.getMonitorings(props.monitoring.id, (_pods: MonitoredResource<Pod>[]) => {
pods.value = _pods;
});
});
function createLabel(input: string)
{
return input.at(0)?.toUpperCase() + input.substring(1, input.length).toLowerCase();
}
</script>

View File

@ -0,0 +1,59 @@
import dayjs, { Dayjs } from "dayjs";
import utc from 'dayjs/plugin/utc';
export function VOLUME_CHART_CONFIG(from: Date, to: Date) {
dayjs.extend(utc);
return {
type: 'line',
options: {
animation: false,
plugins: {
title: {
text: 'Chart.js Time Scale',
display: true
}
},
aspectRatio: 2.75,
scales: {
x: {
type: 'time',
time: {
// Luxon format string
tooltipFormat: 'DD.MM'
},
ticks: {
autoSkip: true,
maxTicksLimit: 6,
align: 'center',
color: '#cacacaff',
callback: function (value: Date) {
const offset = new Date().getTimezoneOffset();
const date: Dayjs = dayjs(value).utcOffset(-offset);
return date.format("HH:mm");
}
},
min: from,
max: to,
grid: {
color: '#cacacaff',
borderColor: '#cacacaff',
lineWidth: 1
}
},
y: {
beginAtZero: true,
max: 100,
ticks: {
stepSize: 20,
color: '#cacacaff',
},
grid: {
color: '#cacacaff',
borderColor: '#cacacaff',
lineWidth: 1
}
}
}
}
}
}

View File

@ -0,0 +1,26 @@
<template>
<div class="content-l">
<p>{{ pod.pod.metadata.namespace }} / {{ pod.pod.metadata.name }}</p>
<Chart :datasets="dataset" :config="VOLUME_CHART_CONFIG()"></Chart>
</div>
</template>
<script setup lang="ts">
import { Data, Dataset } from '~/components/chart/Dataset';
import { MonitoredPod } from '../MonitoredResource';
import { VOLUME_CHART_CONFIG } from './ChartConfig';
const props = defineProps<{
pod: MonitoredPod
}>();
const dataset = computed(() => {
const result = [] as Data[];
for (const job of props.pod.jobs)
{
result.push(new Data(job.average, job.timestamp));
}
return new Dataset("", result);
});
</script>

View File

@ -0,0 +1,34 @@
<template>
<div class="content-l">
<div class="content-s">
<h2>{{ createLabel(monitoring.type) }} Monitoring: {{ monitoring.configName }}</h2>
<p>{{ monitoring.volumeConfig!.mountPath }}</p>
</div>
<div class="col-3">
<MonitoredPodComponent class="tile-l" v-for="pod in pods" :pod="pod"></MonitoredPodComponent>
</div>
</div>
</template>
<script setup lang="ts">
import type { VolumeMonitoringConfig } from '../MonitoringConfig';
import MonitoredPodComponent from './VolumeMonitoredPodComponent.vue';
import { MonitoredPod } from '../MonitoredResource';
const props = defineProps<{
monitoring: VolumeMonitoringConfig
}>();
const pods: Ref<MonitoredPod[] | undefined> = ref(undefined);
onMounted(() => {
MonitoredPod.get(props.monitoring.id, (_pods: MonitoredPod[]) => {
pods.value = _pods;
});
});
function createLabel(input: string)
{
return input.at(0)?.toUpperCase() + input.substring(1, input.length).toLowerCase();
}
</script>

View File

@ -27,9 +27,9 @@ defineProps<{
.field input, .field .input, .field textarea, .field select { .field input, .field .input, .field textarea, .field select {
min-height: 2.5rem; min-height: 2.5rem;
border-radius: 0.25rem; border-radius: 0.25rem;
background-color: rgb(255, 255, 255); background-color: rgb(51, 51, 51);
width: 100%; width: 100%;
border: 2px solid #303030; border: 2px solid #444444;
outline: none; outline: none;
padding: 0.25rem; padding: 0.25rem;
font-size: 1rem; font-size: 1rem;

53
package-lock.json generated
View File

@ -9,8 +9,13 @@
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.0", "@pinia/nuxt": "^0.11.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-adapter-moment": "^1.0.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"luxon": "^3.7.2",
"moment": "^2.30.1",
"nuxt": "^3.17.4", "nuxt": "^3.17.4",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"vue": "^3.5.15", "vue": "^3.5.15",
@ -981,6 +986,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
},
"node_modules/@kwsites/file-exists": { "node_modules/@kwsites/file-exists": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
@ -3999,6 +4009,35 @@
} }
] ]
}, },
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs-adapter-luxon": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/chartjs-adapter-luxon/-/chartjs-adapter-luxon-1.3.1.tgz",
"integrity": "sha512-yxHov3X8y+reIibl1o+j18xzrcdddCLqsXhriV2+aQ4hCR66IYFchlRXUvrJVoxglJ380pgytU7YWtoqdIgqhg==",
"peerDependencies": {
"chart.js": ">=3.0.0",
"luxon": ">=1.0.0"
}
},
"node_modules/chartjs-adapter-moment": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/chartjs-adapter-moment/-/chartjs-adapter-moment-1.0.1.tgz",
"integrity": "sha512-Uz+nTX/GxocuqXpGylxK19YG4R3OSVf8326D+HwSTsNw1LgzyIGRo+Qujwro1wy6X+soNSnfj5t2vZ+r6EaDmA==",
"peerDependencies": {
"chart.js": ">=3.0.0",
"moment": "^2.10.2"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@ -6563,9 +6602,9 @@
} }
}, },
"node_modules/luxon": { "node_modules/luxon": {
"version": "3.6.1", "version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -6817,6 +6856,14 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"engines": {
"node": "*"
}
},
"node_modules/mrmime": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",

View File

@ -12,8 +12,13 @@
"dependencies": { "dependencies": {
"@pinia/nuxt": "^0.11.0", "@pinia/nuxt": "^0.11.0",
"axios": "^1.9.0", "axios": "^1.9.0",
"chart.js": "^4.5.0",
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-adapter-moment": "^1.0.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"luxon": "^3.7.2",
"moment": "^2.30.1",
"nuxt": "^3.17.4", "nuxt": "^3.17.4",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"vue": "^3.5.15", "vue": "^3.5.15",

21
pages/account.vue Normal file
View File

@ -0,0 +1,21 @@
<template>
<div class="account-page">
<NuxtPage></NuxtPage>
<div class="left-center footer">
<NuxtLink to="/account/inspect">Inspect</NuxtLink>
<NuxtLink to="/account/monitorings/nodes">Monitorings</NuxtLink>
</div>
</div>
</template>
<style scoped>
.account-page {
height: 100%;
display: grid;
grid-template-rows: 1fr auto;
}
.footer {
background-color: rgb(12, 12, 12);
padding: 1rem;
}
</style>

View File

@ -1,6 +1,6 @@
<template> <template>
<div id="app"> <div id="app">
<Sidebar></Sidebar> <InspectSidebar></InspectSidebar>
<ScrollComponent> <ScrollComponent>
<NuxtPage></NuxtPage> <NuxtPage></NuxtPage>
</ScrollComponent> </ScrollComponent>
@ -9,6 +9,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useNamespaceStore } from '#imports'; import { useNamespaceStore } from '#imports';
import InspectSidebar from '~/components/InspectSidebar.vue';
const namespaceStore = useNamespaceStore(); const namespaceStore = useNamespaceStore();
onMounted(() => { onMounted(() => {

View File

@ -0,0 +1,25 @@
<template>
<div class="monitorings-page">
<SidebarTemplate>
<div class="content-l">
<h2>Kubooboo</h2>
<NuxtLink class="resources" to="/account/monitorings/nodes">Nodes</NuxtLink>
</div>
</SidebarTemplate>
<NuxtPage></NuxtPage>
</div>
</template>
<script setup lang="ts">
import SidebarTemplate from '~/components/SidebarTemplate.vue';
</script>
<style scoped>
.monitorings-page {
display: grid;
grid-template-columns: auto 1fr;
}
.monitorings-page > * {
padding: 1rem;
}
</style>

View File

@ -0,0 +1,14 @@
<template>
<VolumeMonitoringPage></VolumeMonitoringPage>
</template>
<script setup lang="ts">
import VolumeMonitoringPage from '~/components/monitorings/VolumeMonitoringPage.vue';
</script>
<style scoped>
.monitorings-page {
display: grid;
grid-template-columns: auto 1fr;
}
</style>

View File

@ -0,0 +1,7 @@
<template>
<NodeMonitoringPage></NodeMonitoringPage>
</template>
<script setup lang="ts">
import NodeMonitoringPage from '~/components/monitorings/NodeMonitoringPage.vue';
</script>

View File

@ -39,7 +39,7 @@ function doLogin()
const decode = jwtDecode(token) as any; const decode = jwtDecode(token) as any;
getUser(decode.upn, token, (user: User) => { getUser(decode.upn, token, (user: User) => {
setSessionCookie(new Session(user, token), decode.exp as number); setSessionCookie(new Session(user, token), decode.exp as number);
useRouter().push('/dashboard/nodes'); useRouter().push('/account/inspect');
}); });
}); });
} }