💄 Improved UI
This commit is contained in:
parent
e3ff52b020
commit
7b05306369
@ -58,6 +58,8 @@ html, body, #__nuxt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f3f3f3;
|
||||
font-size: 0.95rem;
|
||||
height: 2rem;
|
||||
}
|
||||
.resource:hover > .grid-element {
|
||||
background-color: #dbdbdb;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 227 KiB |
@ -1,9 +1,10 @@
|
||||
import type { Metadata } from "./Metadata";
|
||||
import type { HasMetadata } from "./repo/ResourceRepo";
|
||||
|
||||
export class ConfigMap
|
||||
export class ConfigMap implements HasMetadata
|
||||
{
|
||||
constructor (
|
||||
public metadata: Metadata,
|
||||
public data: Record<string, string>
|
||||
public data?: Record<string, string>
|
||||
) {}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "./Metadata";
|
||||
import type { HasMetadata } from "./ResourceRepo";
|
||||
import type { HasMetadata } from "./repo/ResourceRepo";
|
||||
|
||||
export class Node implements HasMetadata
|
||||
{
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import dayjs from "dayjs";
|
||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||
import type { Metadata } from "./Metadata";
|
||||
import type { HasMetadata } from "./ResourceRepo";
|
||||
import type { HasMetadata } from "./repo/ResourceRepo";
|
||||
|
||||
export class Pod implements HasMetadata
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { Metadata } from "./Metadata";
|
||||
import type { HasMetadata } from "./ResourceRepo";
|
||||
import type { HasMetadata } from "./repo/ResourceRepo";
|
||||
|
||||
export class Secret implements HasMetadata
|
||||
{
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "./Metadata";
|
||||
import type { HasMetadata } from "./ResourceRepo";
|
||||
import type { HasMetadata } from "./repo/ResourceRepo";
|
||||
|
||||
export class Service implements HasMetadata
|
||||
{
|
||||
|
||||
24
classes/repo/AdvancedWebSocket.ts
Normal file
24
classes/repo/AdvancedWebSocket.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
7
classes/repo/ResourceEventType.ts
Normal file
7
classes/repo/ResourceEventType.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export enum ResourceEventType
|
||||
{
|
||||
INIT = "INIT",
|
||||
ADDED = "ADDED",
|
||||
MODIFIED = "MODIFIED",
|
||||
DELETED = "DELETED"
|
||||
}
|
||||
149
classes/repo/ResourceRepo.ts
Normal file
149
classes/repo/ResourceRepo.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
classes/repo/WebsocketSession.ts
Normal file
16
classes/repo/WebsocketSession.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -2,16 +2,15 @@
|
||||
<div>
|
||||
<p class="grid-element">{{ configMap.metadata.name }}</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">
|
||||
<ActionButton v-if="hasAnyRole(getUser(), ['admin', 'maintainer'])">delete</ActionButton>
|
||||
<ActionButton>delete</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ConfigMap } from '~/classes/ConfigMap';
|
||||
import { hasAnyRole } from '~/classes/User';
|
||||
|
||||
defineProps<{
|
||||
configMap: ConfigMap
|
||||
|
||||
@ -1,20 +1,16 @@
|
||||
<template>
|
||||
<SidebarTemplate>
|
||||
<div class="content-l" style="display: grid; grid-template-rows: auto 1fr; height: 100%;">
|
||||
<h2>Kubooboo</h2>
|
||||
<ScrollComponent>
|
||||
<div class="content-l">
|
||||
<div class="nav">
|
||||
<NuxtLink class="resources" v-for="[key, value] of resources" :to="getRoute(key, namespace)" :class="{ 'router-link-active': useRoute().params.resource === key }">{{ value }}</NuxtLink>
|
||||
</div>
|
||||
<div class="divider" :class="{ hide: !inNamespaceScopedResource }"></div>
|
||||
<div class="nav" :class="{ hide: !inNamespaceScopedResource }">
|
||||
<NuxtLink v-for="[key, value] in namespaces" class="namespace" :to="getRoute(resource, key)">{{ value }}</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollComponent>
|
||||
<ScrollComponent>
|
||||
<div class="content-l">
|
||||
<div class="nav">
|
||||
<NuxtLink class="resources" v-for="[key, value] of resources" :to="getRoute(key, namespace)" :class="{ 'router-link-active': useRoute().params.resource === key }">{{ value }}</NuxtLink>
|
||||
</div>
|
||||
<div class="divider" :class="{ hide: !inNamespaceScopedResource }"></div>
|
||||
<div class="nav" :class="{ hide: !inNamespaceScopedResource }">
|
||||
<NuxtLink v-for="[key, value] in namespaces" class="namespace" :to="getRoute(resource, key)">{{ value }}</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ScrollComponent>
|
||||
<div class="left-center" v-if="user" @click="() => accountPopup.open()">
|
||||
<UiIcon>account_circle</UiIcon>
|
||||
<p>{{ user.username }}</p>
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.router-link-active {
|
||||
.resources.router-link-active, .namespace.router-link-active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@ -24,9 +24,3 @@ defineProps<{
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.grid-element {
|
||||
height: 2.25rem;
|
||||
}
|
||||
</style>
|
||||
@ -1,5 +1,5 @@
|
||||
<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="header">
|
||||
<p>Name</p>
|
||||
@ -14,10 +14,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ConfigMap } from '~/classes/ConfigMap';
|
||||
import { ResourceRepo } from '~/classes/ResourceRepo';
|
||||
|
||||
const configMaps = ResourceRepo.init<ConfigMap>().load('config-maps').get();
|
||||
import { ResourceRepo } from '~/classes/repo/ResourceRepo';
|
||||
|
||||
const repo = ResourceRepo.init<ConfigMap>();
|
||||
onMounted(() => {
|
||||
repo.listen("config-maps");
|
||||
});
|
||||
onUnmounted(() => {
|
||||
repo.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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();
|
||||
</script>
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Deployment } from '~/classes/Deployment';
|
||||
import { ResourceRepo } from '~/classes/ResourceRepo';
|
||||
import { ResourceRepo } from '~/classes/repo/ResourceRepo';
|
||||
import DeploymentComponent from '~/components/deployments/DeploymentComponent.vue';
|
||||
|
||||
const deployments = ResourceRepo.init<Deployment>().load('deployments').get();
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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();
|
||||
</script>
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Node } from '~/classes/Node';
|
||||
import { ResourceRepo } from '~/classes/ResourceRepo';
|
||||
import { ResourceRepo } from '~/classes/repo/ResourceRepo';
|
||||
import NodeComponent from '~/components/NodeComponent.vue';
|
||||
|
||||
const node = ResourceRepo.init<Node>().load('nodes').get();
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Pod } from '~/classes/Pod';
|
||||
import { ResourceRepo } from '~/classes/ResourceRepo';
|
||||
import { ResourceRepo } from '~/classes/repo/ResourceRepo';
|
||||
import PodComponent from '~/components/pod/PodComponent.vue';
|
||||
|
||||
const repo = ResourceRepo.init<Pod>();
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ResourceRepo } from '~/classes/ResourceRepo';
|
||||
import { ResourceRepo } from '~/classes/repo/ResourceRepo';
|
||||
import type { Secret } from '~/classes/Secret';
|
||||
import SecretComponent from '~/components/secrets/SecretComponent.vue';
|
||||
import SecretAddComponent from '~/components/secrets/SecretAddComponent.vue';
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ResourceRepo } from '~/classes/ResourceRepo';
|
||||
import { ResourceRepo } from '~/classes/repo/ResourceRepo';
|
||||
import type { Service } from '~/classes/Service';
|
||||
|
||||
const services = ResourceRepo.init<Service>().load('services').get();
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<div class="grid-element action-buttons">
|
||||
<ActionButton @click="showLogPopup = true">text_snippet</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>
|
||||
<PodDeletePopup v-if="showDeletePopup" :pod="pod" @close="showDeletePopup = false"></PodDeletePopup>
|
||||
<LogPopup v-if="showLogPopup" :pod="pod" @close="showLogPopup = false"></LogPopup>
|
||||
@ -22,7 +22,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Pod } from '~/classes/Pod';
|
||||
import { calcAge } from '~/classes/Pod';
|
||||
import { hasAnyRole } from '~/classes/User';
|
||||
import PodDeletePopup from './view/PodDeletePopup.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
<template>
|
||||
<div class="account-page">
|
||||
<NuxtPage></NuxtPage>
|
||||
<div class="left-center footer">
|
||||
<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>
|
||||
<img class="logo" src="/assets/transparent_logo.png" alt="">
|
||||
<div>
|
||||
<h3>Kubooboo</h3>
|
||||
<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>
|
||||
<NuxtPage></NuxtPage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -22,10 +28,15 @@ function logout()
|
||||
.account-page {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
.footer {
|
||||
background-color: rgb(235, 235, 235);
|
||||
padding: 1rem;
|
||||
background-color: var(--tile-color);
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #c0d1ff;
|
||||
}
|
||||
.logo {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user