🎉 Init

This commit is contained in:
Andreas Dinauer 2025-06-05 21:51:14 +02:00
commit b8327507c3
61 changed files with 12763 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

28
app.vue Normal file
View File

@ -0,0 +1,28 @@
<template>
<ClientOnly>
<NuxtPage></NuxtPage>
</ClientOnly>
</template>
<script setup lang="ts">
import type { RouteLocation } from 'vue-router';
// Guard dashboard and redirect to login
useRouter().beforeEach((route: RouteLocation) => {
guard(route.fullPath);
});
guard(useRoute().fullPath);
function guard(route: string)
{
if(route.startsWith('/dashboard') && getToken() == null)
{
useRouter().push('/');
}
}
useHead({
title: 'Kubooboo'
})
</script>

239
assets/base-style.css Normal file
View File

@ -0,0 +1,239 @@
.center {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.left-center {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
}
.right-center {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.stretch-center {
display: flex;
align-items: center;
justify-content: stretch;
gap: 0.5rem;
}
.left-top {
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 0.5rem;
}
.center-top {
display: flex;
align-items: flex-start;
justify-content: center;
}
.center-bottom {
display: flex;
align-items: flex-end;
justify-content: center;
}
.top-center {
display: flex;
align-items: center;
justify-content: flex-start;
}
.spaced-top {
display: flex;
align-items: start;
justify-content: space-between;
}
.spaced-center {
display: flex;
align-items: center;
justify-content: space-between;
}
.pointer {
cursor: pointer;
}
.expand {
height: 100%;
width: 100%;
}
.content, *[class^='content-'], *[class*=' content-'] {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.content, *[class^='content-'], *[class*=' content-'] > *:is(div, form, header, footer) {
width: 100%;
justify-self: stretch;
}
.content .full {
width: 100%;
}
.content-s {
gap: 0.25rem;
}
.content-m {
gap: 0.5rem;
}
.content-l {
gap: 1rem;
}
.content-xl {
display: grid;
gap: 2rem;
}
.content-2xl {
gap: 3rem;
}
.content-3xl {
gap: 4rem;
}
.padding-m {
padding: 0.5rem;
}
.padding-l {
padding: 1rem;
}
.padding-xl {
padding: 2rem;
}
.narrow {
width: min(100%, 1340px);
padding: 0 0.5rem;
}
.narrow.s {
width: min(100%, 320px);
}
.narrow.m {
width: min(100%, 540px);
}
.narrow-b {
width: min(100%, 740px);
}
.tile, *[class^='tile-'], *[class*=' tile-'] {
background-color: var(--background-color);
border-radius: 0.25rem;
overflow: hidden;
width: 100%;
border: 1px solid var(--border-color);
}
.tile-s {
padding: 0.25rem;
}
.tile-m {
padding: 0.5rem;
}
.tile-l {
padding: 1rem;
}
.tile-xl {
padding: 2rem;
}
.col-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.col-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.col-4 {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
.col-5 {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1rem;
}
.grayed-out {
color: #777777;
font-size: 0.85rem;
}
.row_auto-1fr {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
}
.height_100 {
height: 100%;
}
.gap-0 {
gap: 0;
}
.column {
flex-direction: column;
}
@media (max-width: 768px) {
.col-1-m {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.col-2-m {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.col-3-m {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
}
.center-m {
display: flex;
justify-content: center;
align-items: center;
}
}
.full_20rem {
display: grid;
grid-template-columns: 1fr 20rem;
gap: 1rem;
}
.base-shape {
height: 2.5rem;
padding: 0.5rem;
}

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 KiB

48
assets/style.css Normal file
View File

@ -0,0 +1,48 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap');
* {
box-sizing: border-box;
padding: 0;
margin: 0;
font-weight: 600;
font-family: "Source Code Pro", monospace;
}
html {
--primary-color: rgb(87, 75, 255);
}
html, body, #__nuxt {
width: 100vw;
height: 100vh;
}
.resource-container {
display: grid;
grid-template-columns: auto auto auto 1fr auto auto;
align-content: start;
background-color: #ebebeb;
}
.resource, .header {
display: contents;
}
.header > * {
padding: 0.75rem;
background-color: rgb(29, 29, 29);
color: white;
font-weight: bold;
position: sticky;
top: 0;
}
.resource > .grid-element {
padding: 0.25rem 0.75rem;
display: flex;
align-items: center;
}
.resource p {
font-weight: 600;
font-family: "Source Code Pro", monospace;
}
.even > .grid-element {
background-color: rgb(216, 216, 216);
}

6
classes/Ingress.ts Normal file
View File

@ -0,0 +1,6 @@
import type { Metadata } from "./Metadata";
export class Ingress
{
metadata?: Metadata;
}

7
classes/Metadata.ts Normal file
View File

@ -0,0 +1,7 @@
export class Metadata
{
name?: string;
namespace?: string;
creationTimestamp?: string;
uid?: string;
}

6
classes/Namespace.ts Normal file
View File

@ -0,0 +1,6 @@
import type { Metadata } from "./Metadata";
export class Namespace
{
metadata?: Metadata;
}

25
classes/Node.ts Normal file
View File

@ -0,0 +1,25 @@
import type { Metadata } from "./Metadata";
export class NodeStats
{
node?: Node;
relativeCpuUsage?: number;
relativeMemory?: number;
}
class Node
{
metadata?: Metadata;
status?: Status;
}
class Status
{
conditions?: Condition[]
}
class Condition
{
type?: string;
status?: string;
}

111
classes/Pod.ts Normal file
View File

@ -0,0 +1,111 @@
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import type { Metadata } from "./Metadata";
export class Pod
{
metadata?: Metadata
status?: Status
spec?: Spec
}
class Spec {
nodeName?: string;
}
class Status
{
phase?: PodStatus
}
enum PodStatus
{
RUNNING = "Running",
SUCCEEDED = "Succeeded",
FAILED = "Failed"
}
enum ChronoUnit
{
SECOND, MINUTE, HOUR, DAY
}
export function calcAge(datetime: string | undefined)
{
dayjs.extend(advancedFormat);
if(datetime != null)
{
const today = Number(dayjs().format('X'));
const createdAt = Number(dayjs(datetime).format('X'));
const dif = today - createdAt;
if(dif < 60)
{
return format(dif, ChronoUnit.SECOND);
}
if(dif < 60 * 60)
{
const minutes = Math.floor((dif / 60));
return format(minutes, ChronoUnit.MINUTE);
}
if(dif < 60 * 60 * 24)
{
const hours = Math.floor(dif / (60 * 60));
return format(hours, ChronoUnit.HOUR);
}
else
{
const days = Math.floor(dif / (60 * 60 * 24));
return format(days, ChronoUnit.DAY);
}
}
return "-";
}
function format(count: number, unit: ChronoUnit): string
{
if(unit === ChronoUnit.SECOND)
{
if(count != 1)
{
return count + " Sekunden";
}
else
{
return count + " Sekunde";
}
}
if(unit === ChronoUnit.MINUTE)
{
if(count != 1)
{
return count + " Minuten";
}
else
{
return count + " Minute";
}
}
if(unit === ChronoUnit.HOUR)
{
if(count != 1)
{
return count + " Stunden";
}
else
{
return count + " Stunde";
}
}
if(unit === ChronoUnit.DAY)
{
if(count != 1)
{
return count + " Tage";
}
else
{
return count + " Tag";
}
}
return "-";
}

17
classes/Service.ts Normal file
View File

@ -0,0 +1,17 @@
import type { Metadata } from "./Metadata";
export class Service
{
metadata?: Metadata;
spec?: ServiceSpec;
}
export class ServiceSpec
{
type?: ServiceType
}
export enum ServiceType
{
CLUSTER_IP = "ClusterIP"
}

13
classes/Session.ts Normal file
View File

@ -0,0 +1,13 @@
import type { User } from "./User";
export class Session
{
user: User;
token: string;
constructor(user: User, token: string)
{
this.user = user;
this.token = token;
}
}

12
classes/Settings.ts Normal file
View File

@ -0,0 +1,12 @@
export class Settings
{
kubeconfigDefaultPath: boolean;
kubeconfigPath?: string;
refreshInterval: number;
constructor(kubeconfigDefaultPath: boolean, refreshInterval: number)
{
this.kubeconfigDefaultPath = kubeconfigDefaultPath;
this.refreshInterval = refreshInterval;
}
}

24
classes/User.ts Normal file
View File

@ -0,0 +1,24 @@
export class User
{
username?: string;
email?: string;
password?: string;
roles?: string[];
initial?: boolean;
}
export function hasAnyRole(user: User | undefined, requiredRoles: string[])
{
if(user && user.roles != undefined)
{
const roles = user.roles;
for(const role of roles)
{
if(requiredRoles.includes(role))
{
return true;
}
}
}
return false;
}

View File

@ -0,0 +1,63 @@
<template>
<PopupTemplate heading="Account" ref="base">
<div class="account-page">
<div class="nav">
<NuxtLink class="nav-link" :class="{ active: tab === 0 }" @click="() => tab = 0">Allgemein</NuxtLink>
<NuxtLink class="nav-link" :class="{ active: tab === 1 }" @click="() => tab = 1">Einstellungen</NuxtLink>
<NuxtLink class="nav-link" :class="{ active: tab === 2 }" @click="() => tab = 2" v-if="hasAnyRole(getUser(), ['admin'])">Benutzer</NuxtLink>
<NuxtLink class="nav-link" :class="{ active: tab === 3 }" @click="() => tab = 3">Logout</NuxtLink>
</div>
<div>
<AccountTab v-if="tab === 0"></AccountTab>
<SettingsTab v-if="tab === 1"></SettingsTab>
<UserTab v-if="tab === 2" :goto="(n: number) => tab = n"></UserTab>
<LogoutTab v-if="tab === 3"></LogoutTab>
<AddUserTab v-if="tab === 3.5" :goto="(n: number) => tab = n"></AddUserTab>
</div>
</div>
</PopupTemplate>
</template>
<script setup lang="ts">
import UserTab from './account/UserTab.vue';
import SettingsTab from './account/SettingsTab.vue';
import { hasAnyRole } from '~/classes/User';
import LogoutTab from './account/LogoutTab.vue';
import AddUserTab from './account/AddUserTab.vue';
const base = ref();
const tab = ref(0);
function open()
{
tab.value = 0;
base.value.open();
}
defineExpose({
open
})
</script>
<style scoped>
.account-page {
display: grid;
grid-template-columns: auto 1fr;
gap: 2rem;
}
.nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.nav-link {
padding: 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
}
.nav-link.active {
background-color: var(--primary-color);
color: white;
}
</style>

View File

@ -0,0 +1,30 @@
<template>
<div class="action-button">
<span class="material-symbols-outlined"><slot></slot></span>
</div>
</template>
<style>
.action-button {
background-color: darkblue;
height: 1.75rem;
width: 1.75rem;
display: flex;
justify-content: center;
align-items: center;
background-color: black;
border-radius: 0.25rem;
cursor: pointer;
}
.action-button * {
color: white;
font-size: 1rem;
}
.material-symbols-outlined {
font-variation-settings:
'FILL' 0,
'wght' 400,
'GRAD' 0,
'opsz' 24
}
</style>

View File

@ -0,0 +1,14 @@
<template>
<div>
<p class="grid-element" v-if="ingress.metadata">{{ ingress.metadata.name }}</p>
<p class="grid-element" v-if="ingress.metadata">{{ ingress.metadata.namespace }}</p>
</div>
</template>
<script setup lang="ts">
import type { Ingress } from '~/classes/Ingress';
defineProps<{
ingress: Ingress
}>();
</script>

58
components/LogPopup.vue Normal file
View File

@ -0,0 +1,58 @@
<template>
<PopupTemplate :heading="'Logs: ' + pod.metadata?.namespace + '/' + pod.metadata?.name" ref="base">
<ScrollComponent ref="scrollComponent" v-show="logs && logs.length > 0">
<div class="console">
<p class="log" v-for="log, index in logs" :class="{ even: index % 2 }">{{ log }}</p>
</div>
</ScrollComponent>
</PopupTemplate>
</template>
<script setup lang="ts">
import type { Pod } from '~/classes/Pod';
import { getLogs } from '~/requests/logs';
const base = ref();
const logs: Ref<string[] | undefined> = ref(undefined);
const props = defineProps<{
pod: Pod
}>();
const scrollComponent = ref();
function open()
{
logs.value = undefined;
base.value.open();
getLogs(props.pod.metadata?.uid, (_logs: string[]) => {
logs.value = _logs;
scrollComponent.value.scrollToBottom();
});
}
defineExpose({
open
})
</script>
<style scoped>
.log {
width: 100%;
max-width: 100%;
word-break: break-all;
white-space: pre-wrap;
color: white;
padding: 0.25rem 1rem;
}
.console {
background-color: black;
border-radius: 0.5rem;
display: grid;
padding: 1rem 0;
}
.even {
background-color: rgb(24, 24, 24);
}
</style>

View File

@ -0,0 +1,102 @@
<template>
<div>
<p class="grid-element" v-if="nodeStats.node && nodeStats.node.metadata">{{ nodeStats.node.metadata.name }}</p>
<div class="grid-element">
<p>{{ calcAge(nodeStats.node?.metadata?.creationTimestamp) }}</p>
</div>
<div class="grid-element">
<NodeReadyComponent :ready="isReady(nodeStats)"></NodeReadyComponent>
</div>
<div class="grid-element">
<p class="usage" :class="cpuUsageFlag(nodeStats.relativeCpuUsage)">{{ nodeStats.relativeCpuUsage }}%</p>
</div>
<div class="grid-element">
<p class="usage" :class="ramUsageFlag(nodeStats.relativeMemory)">{{ nodeStats.relativeMemory }}%</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { NodeStats } from '~/classes/Node';
import NodeReadyComponent from './NodeReadyComponent.vue';
import { calcAge } from '~/classes/Pod';
defineProps<{
nodeStats: NodeStats;
}>();
function isReady(nodeStats: NodeStats): boolean | undefined
{
const conditions = nodeStats.node?.status?.conditions;
if(conditions != undefined)
{
for(const condition of conditions)
{
if(condition.type === "Ready" && condition.status === "True")
{
return true;
}
}
return false;
}
return undefined;
}
function cpuUsageFlag(usage: number | undefined)
{
if(!usage)
{
return undefined;
}
if(usage < 20)
{
return "green";
}
if(usage < 60)
{
return "orange";
}
return "red";
}
function ramUsageFlag(usage: number | undefined)
{
if(!usage)
{
return undefined;
}
if(usage < 30)
{
return "green";
}
if(usage < 75)
{
return "orange";
}
return "red";
}
</script>
<style scoped>
.usage {
display: flex;
align-items: center;
justify-content: center;
width: 4rem;
padding: 0.25rem;
border-radius: 0.25rem;
background-color: rgb(179, 179, 179);
border-radius: 0.25rem;
}
.green {
background-color: green;
color: white;
}
.orange {
color: white;
background-color: #d68100;
}
.red {
background-color: #ff2a0e;
color: white;
}
</style>

View File

@ -0,0 +1,31 @@
<template>
<div class="phase" :class="{ ready: ready === true, unknown: ready === undefined }">
<p v-if="ready === true">Ready</p>
<p v-if="ready === false">Not Ready</p>
<p v-if="ready === undefined">Unknown</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
ready: boolean | undefined
}>();
</script>
<style scoped>
.phase {
padding: 0.25rem;
border-radius: 0.25rem;
cursor: pointer;
background-color: red;
}
.phase * {
color: white;
}
.phase.ready {
background-color: green;
}
.phase.unknown {
background-color: rgb(0, 119, 119);
}
</style>

View File

@ -0,0 +1,30 @@
<template>
<div v-if="phase" class="phase" :class="phase.toLowerCase()">
<p>{{ phase }}</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
phase: string | undefined
}>();
</script>
<style scoped>
.phase {
padding: 0.25rem;
border-radius: 0.25rem;
}
.phase * {
color: white;
}
.running, .succeeded {
background-color: green;
}
.failed {
background-color: crimson;
}
.pending {
background-color: chocolate;
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<div class="overlay" @click="close" v-show="visible">
<div class="popup" @click.stop>
<div class="popup__header">
<h2>{{ heading }}</h2>
<UiButton icon="close" @click="() => close()" class="square"></UiButton>
</div>
<div class="popup__body">
<slot></slot>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
const visible = ref(false);
function close() {
enableScrolling();
visible.value = false;
}
function open() {
disableScrolling();
visible.value = true;
}
defineProps({
heading: String
})
defineExpose({
close,
open
})
function disableScrolling()
{
const body = document.getElementsByTagName('body');
for(const element of body)
{
element.style.overflow = "hidden";
}
}
function enableScrolling()
{
const body = document.getElementsByTagName('body');
for(const element of body)
{
element.style.overflow = "visible";
}
}
</script>
<style scoped>
.overlay {
background-color: rgba(0, 0, 0, 0.514);
backdrop-filter: blur(0.1rem);
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
padding: 2rem;
z-index: 2;
}
.popup {
display: grid;
grid-template-rows: auto 1fr;
align-content: flex-start;
width: 100%;
height: 100%;
padding: 1rem;
background-color: white;
}
.popup__header {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
}
.popup__body {
height: 100%;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<div class="scroll-component" :id="id">
<div class="inner-scroll-component">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
const id = crypto.randomUUID();
function scrollToBottom()
{
setTimeout(() => {
const element = document.getElementById(id);
if(element)
{
element.scrollTo({top: element.scrollHeight, behavior: 'instant'});
}
}, 25)
}
defineExpose({
scrollToBottom
})
</script>
<style scoped>
.scroll-component {
height: 100%;
position: relative;
overflow: scroll;
}
.inner-scroll-component {
width: 100%;
position: absolute;
min-height: 100%;
}
</style>

85
components/Sidebar.vue Normal file
View File

@ -0,0 +1,85 @@
<template>
<nav class="sidebar">
<div class="content-l">
<h2>Kubooboo</h2>
<div class="nav">
<NuxtLink class="resources" to="/dashboard/nodes">Nodes</NuxtLink>
<NuxtLink class="resources" to="/dashboard/ingresses">Ingresses</NuxtLink>
<NuxtLink class="resources" to="/dashboard/services">Services</NuxtLink>
<NuxtLink class="resources" to="/dashboard/pods">Pods</NuxtLink>
</div>
<div class="divider" :class="{ hide: !inNamespaceScopedResource }"></div>
<div :class="{ hide: !inNamespaceScopedResource }">
<div class="namespace" :class="{ active: currentNamespace === undefined }" @click="() => namespaceStore.selectNamespace(undefined)">
<p>Alle</p>
</div>
<div class="namespace" v-for="namespace in namespaces" @click="() => namespaceStore.selectNamespace(namespace)" :class="{ active: namespace.metadata?.name === currentNamespace?.metadata?.name }">
<p v-if="namespace.metadata">{{ namespace.metadata.name }}</p>
</div>
</div>
</div>
<div class="left-center" v-if="user" @click="() => accountPopup.open()">
<UiIcon>account_circle</UiIcon>
<p>{{ user.username }}</p>
</div>
</nav>
<AccountPopup ref="accountPopup"></AccountPopup>
</template>
<script setup lang="ts">
import { useNamespaceStore } from '#imports';
const namespaceStore = useNamespaceStore();
const namespaces = computed(namespaceStore.getNamespaces);
const currentNamespace = computed(namespaceStore.getCurrentNamespace)
const user = getUser();
const accountPopup = ref();
const inNamespaceScopedResource: ComputedRef<boolean> = computed(() => {
if(useRoute().fullPath.startsWith('/dashboard/nodes'))
{
return false;
}
return true;
});
</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

@ -0,0 +1,43 @@
<template>
<div class="content-l">
<h2>Account</h2>
<div class="content-m">
<h3>Passwort ändern</h3>
<div class="col-2">
<UiInput label="Passwort">
<input type="password" v-model="password">
</UiInput>
<UiInput label="Passwort wiederholen">
<input type="password" v-model="passwordRepeat">
</UiInput>
</div>
<div class="center">
<UiButton :onclick="() => change()" :disabled="password.length === 0 || password !== passwordRepeat">Passwort Ändern</UiButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { changePassword } from '~/requests/user';
const password = ref("");
const passwordRepeat = ref("");
function change()
{
if(password.value !== passwordRepeat.value)
{
throw new Error("[Method: AccountTab.change] passwords do not match.");
}
changePassword(requireUser().username, password.value, () => {
resetFields();
});
}
function resetFields()
{
password.value = "";
passwordRepeat.value = "";
}
</script>

View File

@ -0,0 +1,58 @@
<template>
<div class="content-l">
<h3>Benutzer hinzufügen</h3>
<UiInput label="Username" required>
<input type="text" v-model="user.username">
</UiInput>
<UiInput label="Rolle" required>
<select name="" id="">
<option value="admin">Admin</option>
<option value="maintainer">Maintainer</option>
<option value="developer" selected>Developer</option>
</select>
</UiInput>
<div class="col-2">
<UiInput label="Passwort" required>
<input type="password" v-model="user.password">
</UiInput>
<UiInput label="Passwort wiederholen" required>
<input type="password">
</UiInput>
</div>
<div class="center">
<UiButton class="hollow">Zurück</UiButton>
<UiButton :onclick="() => create()">User Anlegen</UiButton>
</div>
<p>{{ user }}</p>
</div>
</template>
<script setup lang="ts">
import { User } from '~/classes/User';
import { createUser } from '~/requests/user';
const props = defineProps<{
goto: (tab: number) => void
}>();
const user = ref(new User());
function create()
{
if(!user.value.username)
{
throw new Error("username is required");
}
if(!user.value.roles)
{
throw new Error("roles are required");
}
if(!user.value.password)
{
throw new Error("password is required");
}
createUser(user.value, () => {
props.goto(3);
});
}
</script>

View File

@ -0,0 +1,15 @@
<template>
<h3>Logout</h3>
<div>
<UiButton @click="() => logout()">Logout</UiButton>
</div>
</template>
<script setup lang="ts">
function logout()
{
const session = useCookie('session');
session.value = undefined;
useRouter().push('/');
}
</script>

View File

@ -0,0 +1,23 @@
<template>
<div class="content-l">
<h3>Einstellungen</h3>
<div class="content-m">
<UiInput label="Kubeconfig Default Pfad">
<UiRadio code="true" :active="String(settings.kubeconfigDefaultPath)" label="Default" @click="() => settings.kubeconfigDefaultPath = true"></UiRadio>
<UiRadio code="false" :active="String(settings.kubeconfigDefaultPath)" label="Custom" @click="() => settings.kubeconfigDefaultPath = false"></UiRadio>
</UiInput>
<UiInput label="Kubeconfig Pfad">
<input type="text" :disabled="settings.kubeconfigDefaultPath">
</UiInput>
<UiInput label="Refresh Interval [Sekunden]">
<input type="number" v-model="settings.refreshInterval">
</UiInput>
</div>
</div>
</template>
<script setup lang="ts">
import { Settings } from '~/classes/Settings';
const settings = ref(new Settings(true, 10));
</script>

View File

@ -0,0 +1,47 @@
<template>
<div class="content-l">
<div class="left-center">
<h3>Users</h3>
<UiButton icon="add" class="square small" @click="() => goto(3.5)"></UiButton>
</div>
<div class="resource-container user-container">
<div class="header">
<p>Username</p>
<p>E-Mail</p>
<p>Roles</p>
<p>Initial</p>
</div>
<div class="resource" v-for="user, index in users" :class="{ even: index % 2 }">
<p class="grid-element">{{ user.username }}</p>
<p class="grid-element"><span v-if="user.email">{{ user.email }}</span><span v-else>-</span></p>
<p class="grid-element" v-if="user.roles">{{ user.roles.join(", ") }}</p>
<p class="grid-element">{{ user.initial === true }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { User } from '~/classes/User';
import { getUsers } from '~/requests/user';
defineProps<{
goto: (tab: number) => void;
}>();
const users: Ref<User[] | undefined> = ref(undefined);
onMounted(() => {
getUsers((_users: User[]) => {
users.value = _users;
});
})
</script>
<style scoped>
.user-container {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
</style>

View File

@ -0,0 +1,30 @@
<template>
<div>
<p class="grid-element" v-if="pod.metadata">{{ pod.metadata.name }}</p>
<p class="grid-element" v-if="pod.metadata">{{ pod.metadata.namespace }}</p>
<p class="grid-element" v-if="pod.metadata">{{ calcAge(pod.metadata.creationTimestamp) }}</p>
<p class="grid-element" v-if="pod.spec">{{ pod.spec.nodeName }}</p>
<div class="grid-element">
<PhaseComponent v-if="pod.status" :phase="pod.status.phase"></PhaseComponent>
</div>
<div class="grid-element action-buttons">
<ActionButton @click="() => logPopup.open()">text_snippet</ActionButton>
<ActionButton>open_in_full</ActionButton>
<ActionButton @click="() => deletePod(pod.metadata?.uid, () => {})" v-if="hasAnyRole(getUser(), ['admin', 'maintainer'])">delete</ActionButton>
</div>
<LogPopup :pod="pod" ref="logPopup"></LogPopup>
</div>
</template>
<script setup lang="ts">
import type { Pod } from '~/classes/Pod';
import { calcAge } from '~/classes/Pod';
import { hasAnyRole } from '~/classes/User';
import { deletePod } from '~/requests/pod';
defineProps<{
pod: Pod
}>();
const logPopup = ref();
</script>

View File

@ -0,0 +1,19 @@
<template>
<div>
<p class="grid-element" v-if="service.metadata">{{ service.metadata.name }}</p>
<p class="grid-element" v-if="service.metadata">{{ service.metadata.namespace }}</p>
<p class="grid-element" v-if="service.spec">{{ service.spec.type }}</p>
<div class="grid-element">
<ActionButton v-if="hasAnyRole(getUser(), ['admin', 'maintainer'])">delete</ActionButton>
</div>
</div>
</template>
<script setup lang="ts">
import type { Service } from '~/classes/Service';
import { hasAnyRole } from '~/classes/User';
defineProps<{
service: Service
}>();
</script>

View File

@ -0,0 +1,97 @@
<template>
<button @click="() => click()" class="base-shape button pointer center" :class="{ loading: loading, reverse: reverse, disabled: disabled }"><UiIcon v-if="icon && !loading">{{ icon }}</UiIcon><UiLoadingIcon v-if="loading"></UiLoadingIcon><slot></slot></button>
</template>
<script setup lang="ts">
import UiIcon from '@/components/ui/UiIcon.vue';
function click() {
if(props.disabled !== true) {
if(props.onclick != null) {
props.onclick()
}
if(props.to != null) {
useRouter().push(props.to);
}
}
}
const props = defineProps<{
icon?: string,
reverse?: boolean,
loading?: boolean | undefined,
disabled?: boolean,
onclick?: () => void
to?: string
}>();
</script>
<style>
.button {
background-color: var(--primary-color);
color: white;
border: none;
outline: none;
gap: 0.5rem;
border: 2px solid var(--primary-color);
white-space: nowrap;
border-radius: 0.25rem;
font-weight: 500;
}
.button.square {
padding: 0;
aspect-ratio: 1/1;
}
.button:hover {
cursor: pointer;
}
.button * {
color: white;
fill: white;
}
.button.secondary {
background-color: var(--secondary-color);
border-color: var(--secondary-color);
}
.button:hover.secondary {
background-color: var(--secondary-color-alt);
}
.button.reverse {
flex-direction: row-reverse;
}
.button.loading {
background-color: #5c5c5c;
border-color: #5c5c5c;
cursor: default;
}
.button.disabled {
background-color: #7a7a7a;
border-color: #7a7a7a;
cursor: default;
}
.button.hollow {
background-color: white;
border: 2px solid var(--primary-color);;
color: var(--primary-color);
}
.button.hollow * {
color: var(--primary-color);
}
.button.hollow:hover {
background-color: rgba(0, 0, 0, 0.055);
}
.border.secondary.hollow {
border: 2px solid var(--secondary-color);;
color: var(--secondary-color);
}
.reverse {
flex-direction: row-reverse;
}
.button.small {
height: 2rem;
padding: 0 0.5rem;
}
.button.small * {
font-size: 1.25rem;
}
</style>

35
components/ui/UiIcon.vue Normal file
View File

@ -0,0 +1,35 @@
<template>
<span class="material-symbols-outlined"><slot></slot></span>
</template>
<style scoped>
.material-symbols-outlined {
font-variation-settings:
'FILL' 0,
'wght' 400,
'GRAD' 0,
'opsz' 24;
font-size: 1.25rem;
}
.styled {
background-color: var(--primary-color);
color: white;
padding: 0.25rem;
border-radius: 100rem;
}
.styled.secondary {
background-color: var(--secondary-color);
}
.black {
background-color: var(--font-color);
color: var(--background-color);
}
.success {
background-color: #2e9a00;
}
.small {
height: 1.25rem;
width: 1.25rem;
font-size: 0.7rem;
}
</style>

52
components/ui/UiInput.vue Normal file
View File

@ -0,0 +1,52 @@
<template>
<div class="field">
<div class="left-center">
<label v-if="label">{{ label }} <span class="required" v-if="required">*</span></label>
<slot name="label"></slot>
</div>
<slot></slot>
</div>
</template>
<script setup lang="ts">
defineProps<{
label?: string,
required?: boolean
}>();
</script>
<style scoped>
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
</style>
<style>
.field input, .field .input, .field textarea, .field select {
min-height: 2.5rem;
border-radius: 0.25rem;
background-color: rgb(255, 255, 255);
width: 100%;
border: 2px solid #303030;
outline: none;
padding: 0.25rem;
font-size: 1rem;
}
.field textarea {
min-height: 5rem;
padding: 0.25rem;
}
.field input:focus, .field textarea:focus, .field select:focus {
border-color: var(--primary-color);
}
.required {
color: #e63515;
font-weight: bold;
}
.field input:disabled, textarea:disabled, select:disabled {
border: 2px solid #7a7a7a;
background-color: #f1f1f1;
}
</style>

View File

@ -0,0 +1,16 @@
<template>
<UiIcon class="rotate">progress_activity</UiIcon>
</template>
<script setup lang="ts">
import UiIcon from './UiIcon.vue';
</script>
<style scoped>
.rotate{
animation: rotate 1s linear infinite;
}
@keyframes rotate{
to{ transform: rotate(360deg); }
}
</style>

42
components/ui/UiRadio.vue Normal file
View File

@ -0,0 +1,42 @@
<template>
<div class="radio left-center" @click="() => emits('select', code)">
<div class="radio__outer center">
<div class="radio__inner" :class="{ disabled: disabled }" v-if="active != null && active === code"></div>
</div>
<label>{{ label }}</label>
</div>
</template>
<script setup lang="ts">
defineProps<{
active: string | undefined,
disabled?: boolean,
label: string,
code: string | undefined
}>();
const emits = defineEmits<{
(e: 'select', payload: string | undefined): void
}>();
</script>
<style scoped>
* {
user-select: none;
}
.radio__outer {
height: 1.5rem;
width: 1.5rem;
background-color: white;
border: 2px solid black;
border-radius: 0.75rem;
}
.radio__inner {
height: 1rem;
width: 1rem;
background-color: var(--primary-color);
border-radius: 0.75rem;
}
.radio__inner.disabled {
background-color: #7a7a7a;
}
</style>

26
nuxt.config.ts Normal file
View File

@ -0,0 +1,26 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-05-15',
devtools: { enabled: true },
css: [
'@/assets/style.css',
'@/assets/base-style.css'
],
runtimeConfig: {
public: {
apiBase: 'http://localhost:9090'
}
},
app: {
head: {
link: [
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200' }
]
}
},
modules: ['@pinia/nuxt']
});

10467
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@pinia/nuxt": "^0.11.0",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"jwt-decode": "^4.0.0",
"nuxt": "^3.17.4",
"pinia": "^3.0.2",
"vue": "^3.5.15",
"vue-router": "^4.5.1"
}
}

26
pages/dashboard.vue Normal file
View File

@ -0,0 +1,26 @@
<template>
<div id="app">
<Sidebar></Sidebar>
<ScrollComponent>
<NuxtPage></NuxtPage>
</ScrollComponent>
</div>
</template>
<script setup lang="ts">
import { useNamespaceStore } from '#imports';
const namespaceStore = useNamespaceStore();
onMounted(() => {
namespaceStore.init();
})
</script>
<style scoped>
#app {
display: grid;
grid-template-columns: auto 1fr;
min-height: 100%;
align-items: flex-start;
}
</style>

View File

@ -0,0 +1,3 @@
<template>
</template>

View File

@ -0,0 +1,33 @@
<template>
<div class="resource-container ingress-container">
<div class="header">
<p>Name</p>
<p>Namespace</p>
</div>
<IngressComponent :ingress="ingress" v-for="ingress, index in ingresses" class="resource" :class="{ even: index % 2 }"></IngressComponent>
</div>
</template>
<script setup lang="ts">
import type { Ingress } from '~/classes/Ingress';
import IngressComponent from '~/components/IngressComponent.vue';
import { getIngresses } from '~/requests/ingresses';
const ingresses: Ref<Ingress[] | undefined> = ref(undefined);
const namespace = computed(useNamespaceStore().getCurrentNamespace);
onMounted(() => {
watch(namespace, (newNamespace) => {
getIngresses(newNamespace?.metadata?.name, (_ingresses: Ingress[]) => {
ingresses.value = _ingresses;
});
}, { immediate: true })
})
</script>
<style scoped>
.ingress-container {
grid-template-columns: 1fr 1fr;
}
</style>

47
pages/dashboard/nodes.vue Normal file
View File

@ -0,0 +1,47 @@
<template>
<div class="resource-container node-container">
<div class="header">
<p>Name</p>
<p>Alter</p>
<p>Status</p>
<p>CPU</p>
<p>RAM</p>
</div>
<NodeComponent :node-stats="ns" v-for="ns, index in nodeStats" class="resource" :class="{ even: index % 2 }"></NodeComponent>
</div>
</template>
<script setup lang="ts">
import { NodeStats } from '~/classes/Node';
import { getNodes } from '~/requests/nodes';
const nodeStats: Ref<NodeStats[] | undefined> = ref(undefined);
let interval: NodeJS.Timeout | undefined = undefined;
onMounted(() => {
loadNodes();
interval = setInterval(() => {
loadNodes();
}, 10000)
})
onUnmounted(() => {
clearInterval(interval);
});
function loadNodes()
{
getNodes((_nodes: NodeStats[]) => {
nodeStats.value = _nodes;
});
}
</script>
<style>
.node-container {
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
</style>

55
pages/dashboard/pods.vue Normal file
View File

@ -0,0 +1,55 @@
<template>
<div class="resource-container pod-container">
<div class="header">
<p>Pod</p>
<p>Namespace</p>
<p>Alter</p>
<p>Node</p>
<p>Status</p>
<p>Aktionen</p>
</div>
<PodComponent v-for="pod, index in pods" :pod="pod" class="resource" :class="{ even: index % 2 }"></PodComponent>
</div>
</template>
<script setup lang="ts">
import { type Pod } from '~/classes/Pod';
import { getPods } from '~/requests/pod';
import { useNamespaceStore } from '#imports';
import type { Namespace } from '~/classes/Namespace';
const pods: Ref<Pod[] | undefined> = ref(undefined);
const namespace = computed(useNamespaceStore().getCurrentNamespace);
let interval: NodeJS.Timeout | undefined = undefined;
onMounted(() => {
watch(namespace, (newNamespace) => {
loadPods(newNamespace);
clearInterval(interval);
interval = setInterval(() => {
loadPods(newNamespace);
}, 10000);
}, { immediate: true })
})
onUnmounted(() => {
clearInterval(interval);
});
function loadPods(namespace?: Namespace)
{
getPods(namespace?.metadata?.name, (_pods: Pod[]) => {
pods.value = _pods;
});
}
</script>
<style>
.pod-container {
grid-template-columns: auto auto auto 1fr auto auto;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
</style>

View File

@ -0,0 +1,53 @@
<template>
<div class="resource-container service-container">
<div class="header">
<p>Service</p>
<p>Namespace</p>
<p>Type</p>
<p>Aktionen</p>
</div>
<ServiceComponent :service="service" v-for="service, index in services" class="resource" :class="{ even: index % 2 }"></ServiceComponent>
</div>
</template>
<script setup lang="ts">
import { useNamespaceStore } from '#imports';
import type { Namespace } from '~/classes/Namespace';
import type { Service } from '~/classes/Service';
import { getServices } from '~/requests/services';
const services: Ref<Service[] | undefined> = ref(undefined);
const namespace = computed(useNamespaceStore().getCurrentNamespace);
let interval: NodeJS.Timeout | undefined = undefined;
onMounted(() => {
watch(namespace, (newNamespace) => {
loadServices(newNamespace);
clearInterval(interval);
interval = setInterval(() => {
loadServices(newNamespace);
}, 10000);
}, { immediate: true })
})
onUnmounted(() => {
clearInterval(interval);
})
function loadServices(namespace?: Namespace)
{
getServices(namespace?.metadata?.name, (_services: Service[]) => {
services.value = _services;
});
}
</script>
<style>
.service-container {
grid-template-columns: auto auto 1fr auto;
}
.action-buttons {
display: flex;
gap: 0.5rem;
}
</style>

75
pages/index.vue Normal file
View File

@ -0,0 +1,75 @@
<template>
<div class="login-wrapper">
<h2>Kubooboo</h2>
<img class="logo" src="@/assets/logo.png" alt="">
<form class="login content-xl" @submit.prevent="doLogin()">
<div class="center">
<h1>Login</h1>
</div>
<div class="content-l">
<UiInput label="Username">
<input type="text" v-model="loginCredentials.username">
</UiInput>
<UiInput label="Passwort">
<input type="password" v-model="loginCredentials.password">
</UiInput>
</div>
<div class="center">
<UiButton @onclick="() => doLogin()" :loading="loading" icon="login" reverse>Login</UiButton>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { User } from '~/classes/User';
import UiInput from '~/components/ui/UiInput.vue';
import { login } from '~/requests/login';
import { jwtDecode } from 'jwt-decode';
import { getUser } from '~/requests/user';
import { Session } from '~/classes/Session';
const loginCredentials = ref(new User());
const loading = ref(false);
function doLogin()
{
loading.value = true;
login(loginCredentials.value, (token: string) => {
const decode = jwtDecode(token) as any;
getUser(decode.upn, token, (user: User) => {
setSessionCookie(new Session(user, token), decode.exp as number);
useRouter().push('/dashboard/nodes');
});
});
}
function setSessionCookie(session: Session, exp: number)
{
const cookie = useCookie('session', {
expires: new Date(exp * 1000)
});
cookie.value = JSON.stringify(session);
}
</script>
<style scoped>
.login-wrapper {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.login {
width: 640px;
background-color: #f1f1f1;
padding: 2rem;
border-radius: 0.5rem;
}
.logo {
width: 10rem;
height: 10rem;
}
</style>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

27
requests/ingresses.ts Normal file
View File

@ -0,0 +1,27 @@
import axios from "axios";
import type { Ingress } from "~/classes/Ingress";
export function getIngresses(namespace: string | undefined, onSuccess: (ingresses: Ingress[]) => void)
{
axios.get<Ingress[]>(useRuntimeConfig().public.apiBase + '/ingresses', {
headers: {
Authorization: "Bearer " + requireToken()
},
params: getParams(namespace)
})
.then(response => {
onSuccess(response.data);
})
.catch();
}
function getParams(namespace: string | undefined)
{
if(namespace != undefined)
{
return {
namespace: namespace
}
}
return undefined;
}

11
requests/login.ts Normal file
View File

@ -0,0 +1,11 @@
import axios from "axios";
import type { User } from "~/classes/User";
export function login(user: User, onSuccess: (token: string) => void)
{
axios.post<string>(useRuntimeConfig().public.apiBase + '/login', user)
.then((response) => {
onSuccess(response.data);
})
.catch();
}

13
requests/logs.ts Normal file
View File

@ -0,0 +1,13 @@
import axios from "axios";
export function getLogs(podId: string | undefined, onSuccess: (logs: string[]) => void)
{
axios.get<string[]>(useRuntimeConfig().public.apiBase + '/pods/' + podId + "/logs", {
headers: {
Authorization: "Bearer " + requireToken()
}
})
.then((response) => {
onSuccess(response.data);
});
}

16
requests/namespace.ts Normal file
View File

@ -0,0 +1,16 @@
import axios from "axios";
import type { Namespace } from "~/classes/Namespace";
export function getNamespaces(onSuccess: (namespaces: Namespace[]) => void)
{
axios.get<Namespace[]>(useRuntimeConfig().public.apiBase + '/namespaces', {
headers: {
Authorization: "Bearer " + requireToken()
}
})
.then((response) => {
onSuccess(response.data);
})
.catch();
}

16
requests/nodes.ts Normal file
View File

@ -0,0 +1,16 @@
import axios from "axios";
import type { NodeStats } from "~/classes/Node";
export function getNodes(onSuccess: (nodes: NodeStats[]) => void)
{
axios.get<NodeStats[]>(useRuntimeConfig().public.apiBase + '/nodes', {
headers: {
Authorization: "Bearer " + requireToken()
}
})
.then((response) => {
onSuccess(response.data);
})
.catch();
}

40
requests/pod.ts Normal file
View File

@ -0,0 +1,40 @@
import axios from "axios";
import type { Pod } from "~/classes/Pod";
export function getPods(namespace: string | undefined, onSuccess: (pods: Pod[]) => void)
{
axios.get<Pod[]>(useRuntimeConfig().public.apiBase + '/pods', {
headers: {
Authorization: "Bearer " + requireToken()
},
params: getParams(namespace)
})
.then((response) => {
onSuccess(response.data);
})
.catch();
}
export function deletePod(id: string | undefined, onSuccess: () => void)
{
axios.delete(useRuntimeConfig().public.apiBase + '/pods/' + id, {
headers: {
Authorization: "Bearer " + requireToken()
}
})
.then(() => {
onSuccess();
})
.catch();
}
function getParams(namespace: string | undefined)
{
if(namespace != undefined)
{
return {
namespace: namespace
}
}
return undefined;
}

27
requests/services.ts Normal file
View File

@ -0,0 +1,27 @@
import axios, { type AxiosRequestConfig } from "axios";
import type { Service } from "~/classes/Service";
export function getServices(namespace: string | undefined, onSuccess: (services: Service[]) => void)
{
axios.get<Service[]>(useRuntimeConfig().public.apiBase + '/services', {
headers: {
Authorization: "Bearer " + requireToken()
},
params: getParams(namespace)
})
.then(response => {
onSuccess(response.data);
})
.catch();
}
function getParams(namespace: string | undefined)
{
if(namespace != undefined)
{
return {
namespace: namespace
}
}
return undefined;
}

59
requests/user.ts Normal file
View File

@ -0,0 +1,59 @@
import axios from "axios";
import type { User } from "~/classes/User";
export function getUser(username: string, token: string, onSuccess: (user: User) => void)
{
axios.get<User>(useRuntimeConfig().public.apiBase + '/users/' + username, {
headers: {
Authorization: "Bearer " + token
}
})
.then((response) => {
onSuccess(response.data);
})
.catch();
}
export function getUsers(onSuccess: (users: User[]) => void)
{
axios.get<User[]>(useRuntimeConfig().public.apiBase + '/users', {
headers: {
Authorization: "Bearer " + requireToken()
}
})
.then((response) => {
onSuccess(response.data);
})
.catch();
}
export function createUser(user: User, onSuccess: () => void)
{
axios.post(useRuntimeConfig().public.apiBase + '/users', user, {
headers: {
Authorization: "Bearer " + requireToken()
}
})
.then(() => {
onSuccess();
})
.catch();
}
export function changePassword(username: string | undefined, password: string, onSuccess: () => void)
{
if(username == null)
{
throw new Error("[Method: changePassword] username is undefined.");
}
axios.put(useRuntimeConfig().public.apiBase + '/users/' + username + '/password', password, {
headers: {
Authorization: "Bearer " + requireToken(),
"Content-Type": "text/plain"
}
})
.then(() => {
onSuccess();
})
.catch();
}

3
server/tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

38
stores/NamespaceStore.ts Normal file
View File

@ -0,0 +1,38 @@
import type { Namespace } from "~/classes/Namespace"
import { getNamespaces } from "~/requests/namespace"
export const useNamespaceStore = defineStore('namespace', {
state: () => ({
namespaces: undefined as Namespace[] | undefined,
currentNamespace: undefined as Namespace | undefined
}),
getters: {
getNamespaces: (state) => {
return (): Namespace[] | undefined => state.namespaces;
},
getCurrentNamespace: (state) => {
return (): Namespace | undefined => state.currentNamespace;
}
},
actions: {
init() {
getNamespaces((namespaces: Namespace[]) => {
this.namespaces = namespaces;
});
},
selectNamespace(namespace: Namespace | undefined) {
if(namespace == null)
{
this.currentNamespace = undefined;
}
else if(this.currentNamespace?.metadata?.name !== namespace?.metadata?.name)
{
this.currentNamespace = namespace;
}
else
{
this.currentNamespace = undefined;
}
}
},
})

4
tsconfig.json Normal file
View File

@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

47
utils/cookie.ts Normal file
View File

@ -0,0 +1,47 @@
import type { Session } from "~/classes/Session";
import type { User } from "~/classes/User";
export function requireToken(): string
{
const token = getToken();
if(token != null)
{
return token;
}
throw new Error("No session set. Log in first.");
}
export function getToken(): string | undefined
{
const session = getSession();
if(session != null)
{
return session.token;
}
return undefined;
}
export function getUser(): User | undefined
{
const session = getSession();
if(session != null)
{
return session.user;
}
return undefined;
}
export function requireUser(): User
{
const session = getSession();
if(session != null)
{
return session.user;
}
throw new Error("User is required but undefined");
}
function getSession()
{
return useCookie<Session | undefined>('session').value;
}