💄 Improved UI

This commit is contained in:
andreas.dinauer 2025-11-08 10:41:33 +01:00
parent e3ff52b020
commit 7b05306369
26 changed files with 255 additions and 192 deletions

View File

@ -58,6 +58,8 @@ html, body, #__nuxt {
display: flex; display: flex;
align-items: center; align-items: center;
background-color: #f3f3f3; background-color: #f3f3f3;
font-size: 0.95rem;
height: 2rem;
} }
.resource:hover > .grid-element { .resource:hover > .grid-element {
background-color: #dbdbdb; background-color: #dbdbdb;
@ -68,4 +70,4 @@ html, body, #__nuxt {
} }
.even > .grid-element { .even > .grid-element {
background-color: rgb(233, 233, 233); background-color: rgb(233, 233, 233);
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 227 KiB

View File

@ -1,9 +1,10 @@
import type { Metadata } from "./Metadata"; import type { Metadata } from "./Metadata";
import type { HasMetadata } from "./repo/ResourceRepo";
export class ConfigMap export class ConfigMap implements HasMetadata
{ {
constructor ( constructor (
public metadata: Metadata, public metadata: Metadata,
public data: Record<string, string> public data?: Record<string, string>
) {} ) {}
} }

View File

@ -1,5 +1,5 @@
import type { Metadata } from "./Metadata"; import type { Metadata } from "./Metadata";
import type { HasMetadata } from "./ResourceRepo"; import type { HasMetadata } from "./repo/ResourceRepo";
export class Node implements HasMetadata export class Node implements HasMetadata
{ {

View File

@ -1,7 +1,7 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat"; import advancedFormat from "dayjs/plugin/advancedFormat";
import type { Metadata } from "./Metadata"; import type { Metadata } from "./Metadata";
import type { HasMetadata } from "./ResourceRepo"; import type { HasMetadata } from "./repo/ResourceRepo";
export class Pod implements HasMetadata export class Pod implements HasMetadata
{ {

View File

@ -1,140 +0,0 @@
import axios from "axios";
import type { Metadata } from "./Metadata";
export interface HasMetadata {
metadata: Metadata
}
export class ResourceEvent<T extends HasMetadata>
{
constructor (
public type: string,
public resources: T[]
) {}
}
export class ResourceRepo<T extends HasMetadata>
{
private resources: Ref<T[]> = ref([]);
private interval: NodeJS.Timeout | undefined = undefined;
private websocket: WebSocket | undefined = undefined;
static init<T extends HasMetadata>()
{
return new ResourceRepo<T>();
}
listen(resource: string)
{
const websocket = new WebSocket(StringUtils.format("%s/watch/%s/%s", ApiConfig.getWsBase(), resource, this.getNamespace()));
websocket.addEventListener('open', () => {
console.info("Opened Websocket.");
})
websocket.addEventListener("message", (event) => {
const data = JSON.parse(event.data) as ResourceEvent<T>;
console.info(StringUtils.format("[%s] Resource", data.type));
switch (data.type)
{
case "INIT":
{
this.add(data.resources);
break;
}
case "ADDED":
{
this.add(data.resources);
break;
}
case "MODIFIED":
{
this.update(data.resources);
break;
}
case "DELETED":
{
this.delete(data.resources);
break;
}
}
});
const interval = setInterval(() => {
console.info("[PING]");
websocket.send('[PING]');
}, 5000);
websocket.addEventListener("close", () => {
console.info("Closing websocket.");
clearTimeout(interval);
});
this.websocket = websocket;
}
private add(resources: T[])
{
this.resources.value.push(...resources);
}
private delete(resources: T[])
{
for (const resource of resources)
{
const index = this.resources.value.findIndex(item => item.metadata.uid === resource.metadata.uid);
if (index != null)
{
this.resources.value.splice(index, 1);
}
}
}
private update(resources: T[])
{
for (const resource of resources)
{
const index = this.resources.value.findIndex(item => item.metadata.uid === resource.metadata.uid);
if (index != null)
{
this.resources.value[index] = resource;
}
}
}
load(resourceType: string)
{
this.refresh(resourceType);
return this;
}
clear()
{
clearTimeout(this.interval);
if (this.websocket != null)
{
this.websocket.close();
}
}
get()
{
return computed(() => {
return this.resources.value.toSorted((a, b) => a.metadata.name.localeCompare(b.metadata.name));
})
}
private refresh(resourceType: string)
{
const namespace = this.getNamespace();
let url = StringUtils.format("%s/resources/%s", ApiConfig.getHttpBase(), resourceType);
if (namespace)
{
url = StringUtils.format("%s/%s", url, namespace);
}
axios.get<T[]>(url)
.then((response) => {
this.resources.value = response.data;
});
}
private getNamespace()
{
return useRoute().params.namespace as string;
}
}

View File

@ -1,5 +1,5 @@
import { Metadata } from "./Metadata"; import { Metadata } from "./Metadata";
import type { HasMetadata } from "./ResourceRepo"; import type { HasMetadata } from "./repo/ResourceRepo";
export class Secret implements HasMetadata export class Secret implements HasMetadata
{ {

View File

@ -1,5 +1,5 @@
import type { Metadata } from "./Metadata"; import type { Metadata } from "./Metadata";
import type { HasMetadata } from "./ResourceRepo"; import type { HasMetadata } from "./repo/ResourceRepo";
export class Service implements HasMetadata export class Service implements HasMetadata
{ {

View File

@ -0,0 +1,24 @@
export class AdvancedWebSocket
{
constructor (
private websocket: WebSocket,
private timeout: NodeJS.Timeout
) {}
static open(url: string, onMessage: (event: MessageEvent) => void, onClose: (event: CloseEvent) => void)
{
const websocket = new WebSocket(url);
websocket.addEventListener('message', onMessage);
websocket.addEventListener('close', onClose);
const timeout = setInterval(() => {
websocket.send("[PING]");
}, 5000);
return new AdvancedWebSocket(websocket, timeout);
}
close()
{
this.websocket.close();
clearTimeout(this.timeout);
}
}

View File

@ -0,0 +1,7 @@
export enum ResourceEventType
{
INIT = "INIT",
ADDED = "ADDED",
MODIFIED = "MODIFIED",
DELETED = "DELETED"
}

View File

@ -0,0 +1,149 @@
import axios from "axios";
import type { Metadata } from "../Metadata";
import { ResourceEventType } from "./ResourceEventType";
import { AdvancedWebSocket } from "./AdvancedWebSocket";
import { WebsocketSession } from "./WebsocketSession";
export interface HasMetadata {
metadata: Metadata
}
export class ResourceEvent<T extends HasMetadata>
{
constructor (
public type: ResourceEventType,
public resources: T[]
) {}
}
export class ResourceRepo<T extends HasMetadata>
{
private resources: Ref<T[] | undefined> = ref(undefined);
private websocket?: AdvancedWebSocket;
static init<T extends HasMetadata>()
{
return new ResourceRepo<T>();
}
listen(resource: string)
{
WebsocketSession.get((token: string) => {
const webSocket = AdvancedWebSocket.open(StringUtils.format("%s/watch/%s/%s?token=%s", ApiConfig.getWsBase(), resource, this.getNamespace(), token),
(event) => {
const data = JSON.parse(event.data) as ResourceEvent<T>;
switch (data.type)
{
case ResourceEventType.INIT:
{
this.add(data.resources);
break;
}
case ResourceEventType.ADDED:
{
this.add(data.resources);
break;
}
case ResourceEventType.MODIFIED:
{
this.update(data.resources);
break;
}
case ResourceEventType.DELETED:
{
this.delete(data.resources);
break;
}
}
},
() => { }
);
this.websocket = webSocket;
});
}
private add(resources: T[])
{
if (this.resources.value == null)
{
this.resources.value = [];
}
this.resources.value.push(...resources);
}
private delete(resources: T[])
{
if (this.resources.value == null)
{
this.resources.value = [];
}
for (const resource of resources)
{
const index = this.resources.value.findIndex(item => item.metadata.uid === resource.metadata.uid);
if (index != null)
{
this.resources.value.splice(index, 1);
}
}
}
private update(resources: T[])
{
if (this.resources.value == null)
{
this.resources.value = [];
}
for (const resource of resources)
{
const index = this.resources.value.findIndex(item => item.metadata.uid === resource.metadata.uid);
if (index != null)
{
this.resources.value[index] = resource;
}
}
}
load(resourceType: string)
{
this.refresh(resourceType);
return this;
}
clear()
{
if (this.websocket)
{
this.websocket.close();
}
}
get()
{
return computed(() => {
if (this.resources.value)
{
return this.resources.value.toSorted((a, b) => a.metadata.name.localeCompare(b.metadata.name));
}
return undefined;
})
}
private refresh(resourceType: string)
{
const namespace = this.getNamespace();
let url = StringUtils.format("%s/resources/%s", ApiConfig.getHttpBase(), resourceType);
if (namespace)
{
url = StringUtils.format("%s/%s", url, namespace);
}
axios.get<T[]>(url)
.then((response) => {
this.resources.value = response.data;
});
}
private getNamespace()
{
return useRoute().params.namespace as string;
}
}

View File

@ -0,0 +1,16 @@
import axios from "axios";
export class WebsocketSession
{
static get(onSucces: (token: string) => void)
{
axios.post<string>(ApiConfig.getHttpBase() + '/websocket-session', undefined, {
headers: {
Authorization: StringUtils.format("Bearer %s", requireToken())
}
})
.then((response) => {
onSucces(response.data);
});
}
}

View File

@ -2,16 +2,15 @@
<div> <div>
<p class="grid-element">{{ configMap.metadata.name }}</p> <p class="grid-element">{{ configMap.metadata.name }}</p>
<p class="grid-element">{{ configMap.metadata.namespace }}</p> <p class="grid-element">{{ configMap.metadata.namespace }}</p>
<p class="grid-element">{{ Object.keys(configMap.data).length }}</p> <p class="grid-element"><span v-if="configMap.data">{{ Object.keys(configMap.data).length }}</span><span v-else>-</span></p>
<div class="grid-element"> <div class="grid-element">
<ActionButton v-if="hasAnyRole(getUser(), ['admin', 'maintainer'])">delete</ActionButton> <ActionButton>delete</ActionButton>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ConfigMap } from '~/classes/ConfigMap'; import type { ConfigMap } from '~/classes/ConfigMap';
import { hasAnyRole } from '~/classes/User';
defineProps<{ defineProps<{
configMap: ConfigMap configMap: ConfigMap

View File

@ -1,20 +1,16 @@
<template> <template>
<SidebarTemplate> <SidebarTemplate>
<div class="content-l" style="display: grid; grid-template-rows: auto 1fr; height: 100%;"> <ScrollComponent>
<h2>Kubooboo</h2> <div class="content-l">
<ScrollComponent> <div class="nav">
<div class="content-l"> <NuxtLink class="resources" v-for="[key, value] of resources" :to="getRoute(key, namespace)" :class="{ 'router-link-active': useRoute().params.resource === key }">{{ value }}</NuxtLink>
<div class="nav"> </div>
<NuxtLink class="resources" v-for="[key, value] of resources" :to="getRoute(key, namespace)" :class="{ 'router-link-active': useRoute().params.resource === key }">{{ value }}</NuxtLink> <div class="divider" :class="{ hide: !inNamespaceScopedResource }"></div>
</div> <div class="nav" :class="{ hide: !inNamespaceScopedResource }">
<div class="divider" :class="{ hide: !inNamespaceScopedResource }"></div> <NuxtLink v-for="[key, value] in namespaces" class="namespace" :to="getRoute(resource, key)">{{ value }}</NuxtLink>
<div class="nav" :class="{ hide: !inNamespaceScopedResource }"> </div>
<NuxtLink v-for="[key, value] in namespaces" class="namespace" :to="getRoute(resource, key)">{{ value }}</NuxtLink>
</div>
</div>
</ScrollComponent>
</div> </div>
</ScrollComponent>
<div class="left-center" v-if="user" @click="() => accountPopup.open()"> <div class="left-center" v-if="user" @click="() => accountPopup.open()">
<UiIcon>account_circle</UiIcon> <UiIcon>account_circle</UiIcon>
<p>{{ user.username }}</p> <p>{{ user.username }}</p>

View File

@ -24,7 +24,7 @@
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
} }
.router-link-active { .resources.router-link-active, .namespace.router-link-active {
background-color: var(--primary-color); background-color: var(--primary-color);
color: white; color: white;
} }

View File

@ -23,10 +23,4 @@ defineProps<{
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
</style>
<style>
.grid-element {
height: 2.25rem;
}
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<TableComponent :loading="configMaps == null"> <TableComponent :loading="configMaps == null" v-for="configMaps in [repo.get().value]">
<div class="resource-container config-map-container"> <div class="resource-container config-map-container">
<div class="header"> <div class="header">
<p>Name</p> <p>Name</p>
@ -14,10 +14,15 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ConfigMap } from '~/classes/ConfigMap'; import type { ConfigMap } from '~/classes/ConfigMap';
import { ResourceRepo } from '~/classes/ResourceRepo'; import { ResourceRepo } from '~/classes/repo/ResourceRepo';
const configMaps = ResourceRepo.init<ConfigMap>().load('config-maps').get();
const repo = ResourceRepo.init<ConfigMap>();
onMounted(() => {
repo.listen("config-maps");
});
onUnmounted(() => {
repo.clear();
});
</script> </script>
<style scoped> <style scoped>

View File

@ -15,7 +15,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CustomResourceDefinition } from '~/classes/CustomResourceDefinition'; import type { CustomResourceDefinition } from '~/classes/CustomResourceDefinition';
import { ResourceRepo } from '~/classes/ResourceRepo'; import { ResourceRepo } from '~/classes/repo/ResourceRepo';
const customResourceDefinitions = ResourceRepo.init<CustomResourceDefinition>().load('custom-resource-definitions').get(); const customResourceDefinitions = ResourceRepo.init<CustomResourceDefinition>().load('custom-resource-definitions').get();
</script> </script>

View File

@ -14,7 +14,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Deployment } from '~/classes/Deployment'; import type { Deployment } from '~/classes/Deployment';
import { ResourceRepo } from '~/classes/ResourceRepo'; import { ResourceRepo } from '~/classes/repo/ResourceRepo';
import DeploymentComponent from '~/components/deployments/DeploymentComponent.vue'; import DeploymentComponent from '~/components/deployments/DeploymentComponent.vue';
const deployments = ResourceRepo.init<Deployment>().load('deployments').get(); const deployments = ResourceRepo.init<Deployment>().load('deployments').get();

View File

@ -15,7 +15,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Ingress } from '~/classes/Ingress'; import type { Ingress } from '~/classes/Ingress';
import { ResourceRepo } from '~/classes/ResourceRepo'; import { ResourceRepo } from '~/classes/repo/ResourceRepo';
const ingresses = ResourceRepo.init<Ingress>().load('ingresses').get(); const ingresses = ResourceRepo.init<Ingress>().load('ingresses').get();
</script> </script>

View File

@ -16,7 +16,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Node } from '~/classes/Node'; import type { Node } from '~/classes/Node';
import { ResourceRepo } from '~/classes/ResourceRepo'; import { ResourceRepo } from '~/classes/repo/ResourceRepo';
import NodeComponent from '~/components/NodeComponent.vue'; import NodeComponent from '~/components/NodeComponent.vue';
const node = ResourceRepo.init<Node>().load('nodes').get(); const node = ResourceRepo.init<Node>().load('nodes').get();

View File

@ -17,7 +17,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Pod } from '~/classes/Pod'; import type { Pod } from '~/classes/Pod';
import { ResourceRepo } from '~/classes/ResourceRepo'; import { ResourceRepo } from '~/classes/repo/ResourceRepo';
import PodComponent from '~/components/pod/PodComponent.vue'; import PodComponent from '~/components/pod/PodComponent.vue';
const repo = ResourceRepo.init<Pod>(); const repo = ResourceRepo.init<Pod>();

View File

@ -15,7 +15,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ResourceRepo } from '~/classes/ResourceRepo'; import { ResourceRepo } from '~/classes/repo/ResourceRepo';
import type { Secret } from '~/classes/Secret'; import type { Secret } from '~/classes/Secret';
import SecretComponent from '~/components/secrets/SecretComponent.vue'; import SecretComponent from '~/components/secrets/SecretComponent.vue';
import SecretAddComponent from '~/components/secrets/SecretAddComponent.vue'; import SecretAddComponent from '~/components/secrets/SecretAddComponent.vue';

View File

@ -13,7 +13,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ResourceRepo } from '~/classes/ResourceRepo'; import { ResourceRepo } from '~/classes/repo/ResourceRepo';
import type { Service } from '~/classes/Service'; import type { Service } from '~/classes/Service';
const services = ResourceRepo.init<Service>().load('services').get(); const services = ResourceRepo.init<Service>().load('services').get();

View File

@ -11,7 +11,7 @@
<div class="grid-element action-buttons"> <div class="grid-element action-buttons">
<ActionButton @click="showLogPopup = true">text_snippet</ActionButton> <ActionButton @click="showLogPopup = true">text_snippet</ActionButton>
<ActionButton>open_in_full</ActionButton> <ActionButton>open_in_full</ActionButton>
<ActionButton @click="showDeletePopup = true" v-if="hasAnyRole(getUser(), ['admin', 'maintainer'])">delete</ActionButton> <ActionButton @click="showDeletePopup = true">delete</ActionButton>
</div> </div>
<PodDeletePopup v-if="showDeletePopup" :pod="pod" @close="showDeletePopup = false"></PodDeletePopup> <PodDeletePopup v-if="showDeletePopup" :pod="pod" @close="showDeletePopup = false"></PodDeletePopup>
<LogPopup v-if="showLogPopup" :pod="pod" @close="showLogPopup = false"></LogPopup> <LogPopup v-if="showLogPopup" :pod="pod" @close="showLogPopup = false"></LogPopup>
@ -22,7 +22,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Pod } from '~/classes/Pod'; import type { Pod } from '~/classes/Pod';
import { calcAge } from '~/classes/Pod'; import { calcAge } from '~/classes/Pod';
import { hasAnyRole } from '~/classes/User';
import PodDeletePopup from './view/PodDeletePopup.vue'; import PodDeletePopup from './view/PodDeletePopup.vue';
const props = defineProps<{ const props = defineProps<{

View File

@ -1,12 +1,18 @@
<template> <template>
<div class="account-page"> <div class="account-page">
<NuxtPage></NuxtPage>
<div class="left-center footer"> <div class="left-center footer">
<NuxtLink to="/account/inspect/nodes/_all">Inspect</NuxtLink> <img class="logo" src="/assets/transparent_logo.png" alt="">
<NuxtLink to="/account/monitorings/nodes">Monitorings</NuxtLink> <div>
<NuxtLink to="/account/settings">Settings</NuxtLink> <h3>Kubooboo</h3>
<p class="pointer" @click="logout()">Logout</p> <div class="left-center">
<NuxtLink to="/account/inspect/nodes/_all">Inspect</NuxtLink>
<NuxtLink to="/account/monitorings/nodes">Monitorings</NuxtLink>
<NuxtLink to="/account/settings">Settings</NuxtLink>
<p class="pointer" @click="logout()">Logout</p>
</div>
</div>
</div> </div>
<NuxtPage></NuxtPage>
</div> </div>
</template> </template>
@ -22,10 +28,15 @@ function logout()
.account-page { .account-page {
height: 100%; height: 100%;
display: grid; display: grid;
grid-template-rows: 1fr auto; grid-template-rows: auto 1fr;
} }
.footer { .footer {
background-color: rgb(235, 235, 235); background-color: var(--tile-color);
padding: 1rem; padding: 0.5rem;
border-bottom: 1px solid #c0d1ff;
}
.logo {
width: 2.5rem;
height: 2.5rem;
} }
</style> </style>