Compare commits
12 Commits
push-wlrpv
...
14079b25b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 14079b25b7 | |||
| 5bc57ba497 | |||
| 0991d1a013 | |||
| 9c5d8f3bdc | |||
| 5842a16c9b | |||
| 2c8ef59c19 | |||
| 6678cfda7d | |||
| c8f828bfb0 | |||
| 195cc47368 | |||
| eb368f9860 | |||
| d26aa8c9cb | |||
| 471b13739d |
52
src/lib/components/DarkmodeToggle.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
function systemDarkModeEnabled() {
|
||||
const computedStyle = window.getComputedStyle(document.body);
|
||||
if (computedStyle?.colorScheme != null) {
|
||||
return computedStyle.colorScheme.includes("dark");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function updateBodyClass() {
|
||||
document.body.className = darkmode ? "dark" : "light";
|
||||
}
|
||||
|
||||
let darkmode = $state(false);
|
||||
const darkmodeToggleIcon = $derived(darkmode ? "🌝" : "🌚");
|
||||
|
||||
function toggleDarkmode() {
|
||||
darkmode = !darkmode;
|
||||
updateBodyClass()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
darkmode = systemDarkModeEnabled()
|
||||
updateBodyClass()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="darkToggle">
|
||||
<span on:click={toggleDarkmode}>{ darkmodeToggleIcon }</span>
|
||||
</div>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.darkToggle {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
cursor: pointer;
|
||||
position: fixed;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-right: 2px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { clickOutside } from '$lib/utils/mouseEvents';
|
||||
|
||||
export let title: string;
|
||||
export let description: string | null = null;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const close = () => dispatch('close');
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string | null;
|
||||
close(): void
|
||||
}
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
close
|
||||
}: Props = $props()
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
@@ -40,23 +45,25 @@
|
||||
class="dialog"
|
||||
>
|
||||
<div tabindex="-1" id="dialog-title" class="title">
|
||||
<header>
|
||||
<button on:click={close} aria-disabled="false" aria-label="Close" type="button" tabindex="0"
|
||||
><svg viewBox="0 0 24 24" aria-hidden="true" tabindex="-1" height="100%" width="100%"
|
||||
><path
|
||||
d="M6.909 5.636a.9.9 0 1 0-1.273 1.273l5.091 5.09-5.091 5.092a.9.9 0 0 0 1.273 1.273L12 13.273l5.091 5.09a.9.9 0 1 0 1.273-1.272L13.273 12l5.09-5.091a.9.9 0 1 0-1.272-1.273L12 10.727z"
|
||||
></path></svg
|
||||
>
|
||||
</button>
|
||||
<h5>{title}</h5>
|
||||
</header>
|
||||
{#if title.length || description?.length}
|
||||
<header>
|
||||
<button on:click={close} aria-disabled="false" aria-label="Close" type="button" tabindex="0"
|
||||
><svg viewBox="0 0 24 24" aria-hidden="true" tabindex="-1" height="100%" width="100%"
|
||||
><path
|
||||
d="M6.909 5.636a.9.9 0 1 0-1.273 1.273l5.091 5.09-5.091 5.092a.9.9 0 0 0 1.273 1.273L12 13.273l5.091 5.09a.9.9 0 1 0 1.273-1.272L13.273 12l5.09-5.091a.9.9 0 1 0-1.272-1.273L12 10.727z"
|
||||
></path></svg
|
||||
>
|
||||
</button>
|
||||
<h5>{title}</h5>
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<main>
|
||||
<div id="dialog-description">
|
||||
{#if description}
|
||||
{#if description}
|
||||
<div id="dialog-description">
|
||||
{@html description}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!--
|
||||
<div class="alerts">
|
||||
@@ -83,6 +90,8 @@
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
--offset-top: 4rem;
|
||||
padding-top: var(--offset-top);
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
@@ -96,18 +105,26 @@
|
||||
visibility 0.4s ease;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
align-items: center;
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
padding: 1rem;
|
||||
width: calc(100vw - 2rem);
|
||||
height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
> div {
|
||||
max-width: 880px;
|
||||
max-width: unset;
|
||||
// max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
--padding: 1rem;
|
||||
--background-color: #ffffff;
|
||||
--text-color: black;
|
||||
position: relative;
|
||||
background-color: #ffffff;
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
background-clip: padding-box;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
@@ -123,7 +140,6 @@
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
pointer-events: auto;
|
||||
max-height: 90vh;
|
||||
padding: var(--padding);
|
||||
width: calc(880px - calc(--padding * 2));
|
||||
z-index: 2008;
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import { clickOutside } from '$lib/utils/mouseEvents';
|
||||
|
||||
export let options = ['Today', 'Yesterday', 'Last 7 Days', 'Last 30 Days', 'All time'];
|
||||
export let selected;
|
||||
export let selected: string | undefined = undefined;
|
||||
export let placeholder = '';
|
||||
export let label = '';
|
||||
export let icon = undefined;
|
||||
export let icon: unknown = undefined;
|
||||
export let required = false;
|
||||
|
||||
let dropdown: Element;
|
||||
@@ -29,9 +29,7 @@
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
console.log('dropdown element:', dropdown);
|
||||
const outside = clickOutside(event, dropdown);
|
||||
console.log('click outside:', outside);
|
||||
if (outside === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
345
src/lib/components/GlobalSearch.svelte
Normal file
@@ -0,0 +1,345 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Dialog from './Dialog.svelte';
|
||||
import Input from './Input.svelte';
|
||||
import { allRoutes } from '$lib/remote/filesystem.remote.ts';
|
||||
import type { PageRoute } from '$lib/remote/filesystem.remote.ts';
|
||||
|
||||
type ShortcutHandler = (event: KeyboardEvent) => void;
|
||||
interface Shortcut {
|
||||
keys: string[];
|
||||
handler: ShortcutHandler;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MinimalElement {
|
||||
name: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface OverlayData {
|
||||
type: 'elements' | 'pages' | null;
|
||||
content: MinimalElement | unknown;
|
||||
}
|
||||
|
||||
class KeyboardShortcutManager {
|
||||
private shortcuts: Shortcut[] = [];
|
||||
|
||||
constructor() {
|
||||
window.addEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
register(shortcut: Shortcut) {
|
||||
this.shortcuts.push(shortcut);
|
||||
}
|
||||
|
||||
unregisterAll() {
|
||||
this.shortcuts = [];
|
||||
window.removeEventListener('keydown', this.handleKeydown);
|
||||
}
|
||||
|
||||
private handleKeydown = (event: KeyboardEvent) => {
|
||||
const pressedKeys = [
|
||||
event.metaKey ? 'Meta' : '',
|
||||
event.ctrlKey ? 'Control' : '',
|
||||
event.shiftKey ? 'Shift' : '',
|
||||
event.altKey ? 'Alt' : '',
|
||||
event.key.toUpperCase()
|
||||
].filter(Boolean);
|
||||
|
||||
for (const shortcut of this.shortcuts) {
|
||||
if (this.isMatch(shortcut.keys, pressedKeys)) {
|
||||
event.preventDefault();
|
||||
shortcut.handler(event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// some other key, but not overlay is not open. Nothing to do
|
||||
if (!overlayStore.type) return;
|
||||
|
||||
// listen for text, any letter should reset focusIndex
|
||||
const singleLetter = (event.key.length == 1 && event.key.match(/\D/)) || 0 > 0;
|
||||
if (singleLetter) {
|
||||
updateFocus(0);
|
||||
}
|
||||
|
||||
// listen for number as shortcut actions
|
||||
const digit = event.key.match(/\d/)?.[0];
|
||||
if (digit?.length && digit?.length > 0) {
|
||||
setTimeout(() => {
|
||||
filterString = String(filterString)?.replaceAll(digit, '');
|
||||
}, 1);
|
||||
|
||||
updateFocus(Number(digit) - 1);
|
||||
}
|
||||
|
||||
// listen for arrow keys
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
updateFocus(focusIndex + 1 * direction);
|
||||
}
|
||||
|
||||
// listen for enter key
|
||||
if (event.key === 'Enter' && filteredchildren.length > 0) {
|
||||
const { link, path } = filteredchildren[focusIndex];
|
||||
hideOverlay();
|
||||
openElement(link || path);
|
||||
}
|
||||
};
|
||||
|
||||
private isMatch(shortcutKeys: string[], pressedKeys: string[]) {
|
||||
return (
|
||||
shortcutKeys.length === pressedKeys.length &&
|
||||
shortcutKeys.every((key) => pressedKeys.includes(key))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let overlayStore: OverlayData = $state({ type: null, content: null });
|
||||
let pages: Array<PageRoute> = $state([]);
|
||||
|
||||
function showOverlay(type: 'elements' | 'pages', content: unknown) {
|
||||
if (type === overlayStore.type) return hideOverlay();
|
||||
|
||||
overlayStore = { type, content };
|
||||
focusSearchInput();
|
||||
filterString = '';
|
||||
updateFocus(0);
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
overlayStore = { type: null, content: null };
|
||||
}
|
||||
|
||||
async function resolvePages() {
|
||||
pages = await allRoutes();
|
||||
}
|
||||
|
||||
// call as soon as possible, even if blocking
|
||||
resolvePages();
|
||||
|
||||
// setup managers
|
||||
let manager: KeyboardShortcutManager;
|
||||
const className = 'search-container';
|
||||
|
||||
// search & filter
|
||||
let filterString = $state('');
|
||||
let focusIndex = $state(0);
|
||||
|
||||
let filteredchildren = $derived.by(() => {
|
||||
return overlayStore.content?.filter((a) => a?.name.toLowerCase().includes(filterString));
|
||||
});
|
||||
|
||||
const updateFocus = (index: number) => {
|
||||
if (index < 0) index = 0;
|
||||
else if (index >= filteredchildren.length) {
|
||||
index = filteredchildren.length - 1;
|
||||
}
|
||||
|
||||
focusIndex = index;
|
||||
};
|
||||
|
||||
// setup & register
|
||||
const focusSearchInput = () => {
|
||||
setTimeout(() => {
|
||||
const input = document.getElementsByClassName(className)[0]?.getElementsByTagName('input')[0];
|
||||
input.focus();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
function openElement(link: string) {
|
||||
if (String(link)?.startsWith('/')) {
|
||||
goto(link);
|
||||
} else {
|
||||
window.open(link, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
manager = new KeyboardShortcutManager();
|
||||
manager.register({
|
||||
keys: ['Meta', 'K'],
|
||||
handler: () => {
|
||||
if (!window || !('elements' in window)) return;
|
||||
const elements = window?.elements;
|
||||
showOverlay('elements', elements);
|
||||
}
|
||||
});
|
||||
|
||||
manager.register({
|
||||
keys: ['Meta', 'J'],
|
||||
handler: () => showOverlay('pages', pages)
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => manager?.unregisterAll());
|
||||
</script>
|
||||
|
||||
{#if overlayStore.type}
|
||||
<Dialog close={hideOverlay} title="" description="">
|
||||
<div class={className}>
|
||||
<Input label="" bind:value={filterString} placeholder="Search anything..." />
|
||||
|
||||
<ul>
|
||||
{#each filteredchildren as element, index (element)}
|
||||
<li
|
||||
class={index === focusIndex ? 'focus' : ''}
|
||||
on:click={() => 'link' in element && openElement(element.link ?? '/')}
|
||||
>
|
||||
<div class="header">
|
||||
<h3>{element?.name}</h3>
|
||||
<span class="sub">{element?.link}</span>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
{#each Object.entries(element) as [key, value] (key)}
|
||||
<span><b>{key}:</b> {value}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="hint">
|
||||
Press <span class="kbd">Esc</span> to close · <span class="kbd">⌘</span> +
|
||||
<span class="kbd">K</span> to toggle
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
:global(.dialog .title) {
|
||||
--padding: 1.2rem;
|
||||
--background-color: rgba(255, 255, 255, 0.92);
|
||||
--text-color: black;
|
||||
}
|
||||
|
||||
:global(body.dark .dialog .title) {
|
||||
--background-color: rgba(25, 25, 34, 0.92);
|
||||
--text-color: white;
|
||||
}
|
||||
|
||||
:global(.dialog .title .input) {
|
||||
--padding-w: 1rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 700px;
|
||||
position: relative;
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
width: 100%;
|
||||
|
||||
ul {
|
||||
max-height: calc(50vh);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
margin-top: 0.3rem;
|
||||
padding-left: 0;
|
||||
overflow-y: scroll;
|
||||
overflow-y: scroll;
|
||||
max-height: calc(100vh - var(--offset-top) * 4);
|
||||
max-height: 75vh;
|
||||
}
|
||||
|
||||
li {
|
||||
--blur: 18px;
|
||||
border: var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background 0.15s;
|
||||
padding: 0.875rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
background-color: var(--card-bg);
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
gap: 0.375rem;
|
||||
|
||||
&.focus,
|
||||
&:hover {
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 2px);
|
||||
border: 2px solid var(--theme);
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
h3 {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.2px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sub {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.625rem 1.125rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
|
||||
b {
|
||||
color: var(--key);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Small footer hint */
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
|
||||
.kbd {
|
||||
background-color: rgba(0, 0, 0, 0.16);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(.search-container .label-input) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
overflow: unset;
|
||||
}
|
||||
</style>
|
||||
@@ -46,7 +46,7 @@
|
||||
background: var(--theme);
|
||||
padding: 0 1rem;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
color: var(--bg);
|
||||
margin: 1rem 0.5rem 0 0.5rem;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
@@ -66,7 +66,7 @@
|
||||
font-size: 1.5rem;
|
||||
padding: 0;
|
||||
font-weight: 300;
|
||||
color: white !important;
|
||||
color: var(--bg) !important;
|
||||
}
|
||||
|
||||
img {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
export let label: string;
|
||||
export let value: string;
|
||||
export let placeholder: string;
|
||||
export let value: string = '';
|
||||
export let required = false;
|
||||
export let icon: unknown;
|
||||
export let icon: unknown = null;
|
||||
|
||||
let focus = false;
|
||||
</script>
|
||||
@@ -33,6 +33,10 @@
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
:global(body.dark .label-input .input) {
|
||||
background: var(--highlight);
|
||||
}
|
||||
|
||||
.label-input {
|
||||
width: 100%;
|
||||
|
||||
@@ -54,8 +58,9 @@
|
||||
.input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
--padding: 0.75rem;
|
||||
width: calc(100% - (var(--padding) * 2));
|
||||
--padding-h: 0.25rem;
|
||||
--padding-w: 0.75rem;
|
||||
width: calc(100% - (var(--padding-w) * 2));
|
||||
height: 2.5rem;
|
||||
background: #ffffff;
|
||||
align-items: center;
|
||||
@@ -66,7 +71,7 @@
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px var(--padding);
|
||||
padding: var(--padding-h) var(--padding-w);
|
||||
|
||||
&.focus {
|
||||
box-shadow: 0px 0px 0px 4px #7d66654d;
|
||||
|
||||
96
src/lib/components/LXC.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import CubeSide from '$lib/icons/cube-side.svelte';
|
||||
import HardDrive from '$lib/icons/hard-disk.svelte';
|
||||
import Network from '$lib/icons/network.svelte';
|
||||
import CPU from '$lib/icons/cpu.svelte';
|
||||
import Fingerprint from '$lib/icons/fingerprint.svelte';
|
||||
import ExtractUp from '$lib/icons/extract-up.svelte';
|
||||
import InsertDown from '$lib/icons/insert-down.svelte';
|
||||
import Clock from '$lib/icons/clock.svelte';
|
||||
import Memory from '$lib/icons/floppy-disk.svelte';
|
||||
import { formatBytes, formatDuration } from '$lib/utils/conversion';
|
||||
import type { LXC } from '$lib/interfaces/proxmox';
|
||||
|
||||
const { lxc }: { lxc: LXC } = $props();
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<div class="icon"><CubeSide /></div>
|
||||
<span class="name">{lxc.name} <span class="subtle">{lxc.vmid}</span></span>
|
||||
|
||||
<span class={`status ${lxc.status === 'running' ? 'ok' : 'error'}`}></span>
|
||||
</div>
|
||||
|
||||
<div class="resource">
|
||||
<div class="title">
|
||||
<Network />
|
||||
<span>Status</span>
|
||||
</div>
|
||||
<span>{lxc.status}</span>
|
||||
|
||||
<div class="title">
|
||||
<Network />
|
||||
<span>CPUs</span>
|
||||
</div>
|
||||
<span>{lxc.cpus}</span>
|
||||
|
||||
<div class="title">
|
||||
<CPU />
|
||||
<span>CPU</span>
|
||||
</div>
|
||||
<span>{Math.floor(lxc.cpu * 100) / 100}</span>
|
||||
|
||||
<div class="title">
|
||||
<HardDrive />
|
||||
<span>Max Disk</span>
|
||||
</div>
|
||||
<span>{formatBytes(lxc.maxdisk)}</span>
|
||||
|
||||
<div class="title">
|
||||
<Memory />
|
||||
<span>Memory</span>
|
||||
</div>
|
||||
<span>{formatBytes(lxc.mem)}</span>
|
||||
|
||||
<div class="title">
|
||||
<InsertDown />
|
||||
<span>Net In</span>
|
||||
</div>
|
||||
<span>{formatBytes(lxc.netin)}</span>
|
||||
|
||||
<div class="title">
|
||||
<ExtractUp />
|
||||
<span>Net Out</span>
|
||||
</div>
|
||||
<span>{formatBytes(lxc.netout / 8)}</span>
|
||||
|
||||
<div class="title">
|
||||
<Clock />
|
||||
<span>Uptime</span>
|
||||
</div>
|
||||
<span>{formatDuration(lxc.uptime)}</span>
|
||||
|
||||
<div class="title">
|
||||
<Fingerprint />
|
||||
<span>lxc ID</span>
|
||||
</div>
|
||||
<span>{lxc.vmid}</span>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<button on:click={() => goto(`/servers/lxc/${lxc.vmid}`)}>
|
||||
<span>Pod details</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/card.scss';
|
||||
|
||||
.card {
|
||||
flex-grow: 1;
|
||||
max-width: 550px;
|
||||
}
|
||||
</style>
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
imageSource = reader?.result || '';
|
||||
if (imageSource === '') {
|
||||
console.log("no image data, returning")
|
||||
return
|
||||
console.log('no image data, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
// set imageSource to image element
|
||||
@@ -78,27 +78,38 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="liveimage">
|
||||
{#if !fullscreen}
|
||||
<img on:click={() => (fullscreen = !fullscreen)} src={String(imageSource)} id="live-image" />
|
||||
{:else}
|
||||
<Dialog title="Live stream of printer" on:close={() => (fullscreen = false)}>
|
||||
<img style="width: 100%;" src={String(imageSource)} id="live-image" />
|
||||
<span>Last update {timestamp}s ago</span>
|
||||
</Dialog>
|
||||
<div class="fullscreen-container">
|
||||
<Dialog title="Live stream of printer" close={() => (fullscreen = false)}>
|
||||
<img src={String(imageSource)} id="live-image" />
|
||||
<span>Last update {timestamp}s ago</span>
|
||||
</Dialog>
|
||||
|
||||
<img src={String(grey400x225)} />
|
||||
<img src={String(grey400x225)} />
|
||||
</div>
|
||||
{/if}
|
||||
<span>Last update {timestamp}s ago</span>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
img {
|
||||
width: 400px;
|
||||
.liveimage img {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:global(.fullscreen-container .dialog img) {
|
||||
max-width: unset;
|
||||
}
|
||||
|
||||
:global(.fullscreen-container #dialog-title) {
|
||||
max-width: 98vw;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,133 +69,5 @@
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.card {
|
||||
flex-grow: 1;
|
||||
max-width: 550px;
|
||||
|
||||
background: #fbf6f4;
|
||||
box-shadow: var(
|
||||
--str-shadow-s,
|
||||
0px 0px 2px #22242714,
|
||||
0px 1px 4px #2224271f,
|
||||
0px 4px 8px #22242729
|
||||
);
|
||||
pointer-events: all;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
padding: 0.75rem;
|
||||
background-color: white;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
|
||||
.icon {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
|
||||
&.ok {
|
||||
background-color: var(--positive);
|
||||
}
|
||||
&.warning {
|
||||
background-color: var(--warning);
|
||||
}
|
||||
&.error {
|
||||
background-color: var(--negative);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0.5rem;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.resource {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg);
|
||||
|
||||
row-gap: 6px;
|
||||
column-gap: 20px;
|
||||
|
||||
> div,
|
||||
span {
|
||||
display: flex;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.resource .title svg) {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
margin-top: auto;
|
||||
background: white;
|
||||
padding: 0.5rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
|
||||
button {
|
||||
border: none;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
height: unset;
|
||||
border-radius: 0.5rem;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
padding: 0 0.5rem;
|
||||
flex: 1;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
margin-left: -0.5rem;
|
||||
border: 1px solid #eaddd5;
|
||||
border-radius: inherit;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.1s ease;
|
||||
will-change: box-shadow 0.25s;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #077c35;
|
||||
}
|
||||
@import "../styles/card.scss";
|
||||
</style>
|
||||
|
||||
@@ -105,6 +105,8 @@
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import "../styles/card.scss";
|
||||
|
||||
.card-container {
|
||||
background-color: #cab2aa40;
|
||||
border-radius: 0.5rem;
|
||||
@@ -122,99 +124,4 @@
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
flex-grow: 1;
|
||||
max-width: 550px;
|
||||
|
||||
background: #fbf6f4;
|
||||
box-shadow: var(
|
||||
--str-shadow-s,
|
||||
0px 0px 2px #22242714,
|
||||
0px 1px 4px #2224271f,
|
||||
0px 4px 8px #22242729
|
||||
);
|
||||
pointer-events: all;
|
||||
cursor: auto;
|
||||
|
||||
&.not-running {
|
||||
border: 2px dashed var(--theme);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
padding: 0.75rem;
|
||||
background-color: white;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
|
||||
.icon {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
|
||||
&.ok {
|
||||
background-color: var(--positive);
|
||||
}
|
||||
&.warning {
|
||||
background-color: var(--warning);
|
||||
}
|
||||
&.error {
|
||||
background-color: var(--negative);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0.5rem;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.resource {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg);
|
||||
|
||||
row-gap: 6px;
|
||||
column-gap: 20px;
|
||||
|
||||
> div,
|
||||
span {
|
||||
display: flex;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.resource .title svg) {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
margin-top: auto;
|
||||
background: white;
|
||||
padding: 0.5rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #077c35;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
<h2>{title}</h2>
|
||||
<slot name="top-left" />
|
||||
</div>
|
||||
<label>{description}</label>
|
||||
{#if description && description?.length > 0}
|
||||
<label>{description}</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
link: `https://${node.ip}:8006/#v1:0:=node%2F${node.name}:4:=jsconsole::::::`
|
||||
},
|
||||
{ name: 'Graphs', link: `https://${node.ip}:8006/#v1:0:=node%2F${node.name}:4:5::::::` },
|
||||
{ name: 'Web', link: `https://${node.ip}:8006/` }
|
||||
{ name: 'Details', link: `/servers/node/${node.name}` }
|
||||
];
|
||||
|
||||
let { cpuinfo, memory, uptime, loadavg } = node.info;
|
||||
@@ -116,7 +116,7 @@
|
||||
|
||||
<div class="footer">
|
||||
{#each buttons as btn (btn)}
|
||||
<a href={btn.link} target="_blank" rel="noopener noreferrer">
|
||||
<a href={btn.link} target={btn.link[0] === '/' ? '' : '_blank'} rel="noopener noreferrer">
|
||||
<button>
|
||||
<span>{btn.name}</span>
|
||||
</button>
|
||||
@@ -126,163 +126,5 @@
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@keyframes pulse-live {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(0, 212, 57, 0.7);
|
||||
box-shadow: 0 0 0 0 rgba(0, 212, 57, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(0, 212, 57, 0);
|
||||
box-shadow: 0 0 0 10px rgba(0, 212, 57, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(0, 212, 57, 0);
|
||||
box-shadow: 0 0 0 0 rgba(0, 212, 57, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin pulse-dot {
|
||||
&::after {
|
||||
content: '';
|
||||
top: 50%;
|
||||
margin-left: 0.4rem;
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: pulse-live 2s infinite;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
.card {
|
||||
background: #fbf6f4;
|
||||
box-shadow: var(
|
||||
--str-shadow-s,
|
||||
0px 0px 2px #22242714,
|
||||
0px 1px 4px #2224271f,
|
||||
0px 4px 8px #22242729
|
||||
);
|
||||
pointer-events: all;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
padding: 0.75rem;
|
||||
background-color: white;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
|
||||
.icon {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
|
||||
&.ok {
|
||||
--color: var(--positive);
|
||||
@include pulse-dot;
|
||||
}
|
||||
&.warning {
|
||||
background-color: var(--warning);
|
||||
}
|
||||
&.error {
|
||||
background-color: var(--negative);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0.5rem;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.resource {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg);
|
||||
|
||||
row-gap: 6px;
|
||||
max-width: 330px;
|
||||
|
||||
> div,
|
||||
span {
|
||||
display: flex;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.resource .title svg) {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
margin-top: auto;
|
||||
background: white;
|
||||
padding: 0.5rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
|
||||
button {
|
||||
border: none;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
height: unset;
|
||||
border-radius: 0.5rem;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
padding: 0 0.5rem;
|
||||
flex: 1;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
margin-left: -0.5rem;
|
||||
border: 1px solid #eaddd5;
|
||||
border-radius: inherit;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.1s ease;
|
||||
will-change: box-shadow 0.25s;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #077c35;
|
||||
}
|
||||
@import '../styles/card.scss';
|
||||
</style>
|
||||
|
||||
@@ -173,6 +173,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.dark .nav-wrapper.open) {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: var(--bg);
|
||||
|
||||
nav a {
|
||||
&:hover,
|
||||
&.highlight {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
<script lang="ts">
|
||||
import Dialog from './Dialog.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import Certificate from '$lib/icons/certificate.svelte';
|
||||
import { daysUntil } from '$lib/utils/conversion';
|
||||
import JsonViewer from './JsonViewer.svelte';
|
||||
|
||||
export let title = '';
|
||||
export let description = '';
|
||||
export let columns: Array<string> | object;
|
||||
@@ -12,7 +6,6 @@
|
||||
export let links: Array<string> = [];
|
||||
export let footer = '';
|
||||
|
||||
const hasLinks = links?.length > 0;
|
||||
let displayColumns: string[] = [];
|
||||
if (typeof columns === 'object' && !Array.isArray(columns)) {
|
||||
displayColumns = Object.values(columns);
|
||||
@@ -21,13 +14,17 @@
|
||||
</script>
|
||||
|
||||
<div class="main-container">
|
||||
<div class="header">
|
||||
<h2>{title}</h2>
|
||||
<div class="description">{description}</div>
|
||||
</div>
|
||||
{#if title?.length || description?.length}
|
||||
<div class="header">
|
||||
<h2>{title}</h2>
|
||||
<div class="description">{description}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -58,7 +55,7 @@
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
|
||||
95
src/lib/components/VM.svelte
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import Desktop from '$lib/icons/desktop.svelte';
|
||||
import HardDrive from '$lib/icons/hard-disk.svelte';
|
||||
import Network from '$lib/icons/network.svelte';
|
||||
import CPU from '$lib/icons/cpu.svelte';
|
||||
import Fingerprint from '$lib/icons/fingerprint.svelte';
|
||||
import ExtractUp from '$lib/icons/extract-up.svelte';
|
||||
import InsertDown from '$lib/icons/insert-down.svelte';
|
||||
import Clock from '$lib/icons/clock.svelte';
|
||||
import Memory from '$lib/icons/floppy-disk.svelte';
|
||||
import { formatBytes, formatDuration } from '$lib/utils/conversion';
|
||||
import type { VM } from '$lib/interfaces/proxmox';
|
||||
const { vm }: { vm: VM } = $props();
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<div class="icon"><Desktop /></div>
|
||||
<span class="name">{vm.name} <span class="subtle">{vm.vmid}</span></span>
|
||||
|
||||
<span class={`status ${vm.status === 'running' ? 'ok' : 'error'}`}></span>
|
||||
</div>
|
||||
|
||||
<div class="resource">
|
||||
<div class="title">
|
||||
<Network />
|
||||
<span>Status</span>
|
||||
</div>
|
||||
<span>{vm.status}</span>
|
||||
|
||||
<div class="title">
|
||||
<Network />
|
||||
<span>CPUs</span>
|
||||
</div>
|
||||
<span>{vm.cpus}</span>
|
||||
|
||||
<div class="title">
|
||||
<CPU />
|
||||
<span>CPU</span>
|
||||
</div>
|
||||
<span>{Math.floor(vm.cpu * 100) / 100}</span>
|
||||
|
||||
<div class="title">
|
||||
<HardDrive />
|
||||
<span>Max Disk</span>
|
||||
</div>
|
||||
<span>{formatBytes(vm.maxdisk)}</span>
|
||||
|
||||
<div class="title">
|
||||
<Memory />
|
||||
<span>Memory</span>
|
||||
</div>
|
||||
<span>{formatBytes(vm.mem)}</span>
|
||||
|
||||
<div class="title">
|
||||
<InsertDown />
|
||||
<span>Net In</span>
|
||||
</div>
|
||||
<span>{formatBytes(vm.netin)}</span>
|
||||
|
||||
<div class="title">
|
||||
<ExtractUp />
|
||||
<span>Net Out</span>
|
||||
</div>
|
||||
<span>{formatBytes(vm.netout / 8)}</span>
|
||||
|
||||
<div class="title">
|
||||
<Clock />
|
||||
<span>Uptime</span>
|
||||
</div>
|
||||
<span>{formatDuration(vm.uptime)}</span>
|
||||
|
||||
<div class="title">
|
||||
<Fingerprint />
|
||||
<span>VM ID</span>
|
||||
</div>
|
||||
<span>{vm.vmid}</span>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<button on:click={() => goto(`/servers/vm/${vm.name}`)}>
|
||||
<span>VM details</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/card.scss';
|
||||
|
||||
.card {
|
||||
flex-grow: 1;
|
||||
max-width: 550px;
|
||||
}
|
||||
</style>
|
||||
67
src/lib/components/forms/FormDNS.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/Input.svelte';
|
||||
import TextSize from '$lib/icons/text-size.svelte';
|
||||
import Quill from '$lib/icons/quill.svelte';
|
||||
import Tag from '$lib/icons/tag.svelte';
|
||||
import Dropdown from '../Dropdown.svelte';
|
||||
|
||||
const { close }: { close: () => void } = $props();
|
||||
|
||||
const dnsTypes = ['A', 'AAAA', 'CNAME', 'NS', 'SOA', 'MX', 'TXT', 'PTR'];
|
||||
</script>
|
||||
|
||||
<form method="POST">
|
||||
<div class="wrapper">
|
||||
<Input label="Name" icon={TextSize} placeholder="Website name" required />
|
||||
<Dropdown
|
||||
placeholder="Record type"
|
||||
label="Type"
|
||||
required={true}
|
||||
icon={Tag}
|
||||
options={dnsTypes}
|
||||
/>
|
||||
<Input label="Content" icon={Quill} placeholder="IPv4 address" required />
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<button on:click={close} aria-disabled="false" type="button" tabindex="0"
|
||||
><span tabindex="-1">Cancel</span></button
|
||||
>
|
||||
<button class="affirmative" type="submit" tabindex="-1">
|
||||
<span tabindex="-1">Add record</span>
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
<style lang="scss">
|
||||
form {
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 0.75rem 1.5rem;
|
||||
max-width: 100%;
|
||||
height: 2.5rem;
|
||||
width: auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
button {
|
||||
flex: unset;
|
||||
|
||||
span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(form .wrapper div) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -15,22 +15,6 @@
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
letter-spacing: 0.2px;
|
||||
|
||||
&.selected {
|
||||
opacity: 1;
|
||||
letter-spacing: unset;
|
||||
border-bottom-color: var(--color) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
&:not(&:first-of-type) {
|
||||
margin-left: 0.75rem;
|
||||
@@ -40,10 +24,21 @@
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
opacity: 0.7;
|
||||
background: none;
|
||||
opacity: 0.6;
|
||||
margin: 0;
|
||||
padding-bottom: 0.3rem;
|
||||
transition: 0.3s ease-in-out all;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
letter-spacing: 0.2px;
|
||||
|
||||
&.selected {
|
||||
opacity: 1;
|
||||
letter-spacing: unset;
|
||||
border-bottom-color: var(--color) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
282
src/lib/components/prox-describe/VM.svelte
Normal file
@@ -0,0 +1,282 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { formatBytes, formatDuration } from '$lib/utils/conversion';
|
||||
import Cpu from '$lib/icons/cpu.svelte';
|
||||
import Section from '../Section.svelte';
|
||||
import type { Node, VM } from '$lib/interfaces/proxmox';
|
||||
import FloppyDisk from '$lib/icons/floppy-disk.svelte';
|
||||
import HardDisk from '$lib/icons/hard-disk.svelte';
|
||||
import Clock from '$lib/icons/clock.svelte';
|
||||
import InsertDown from '$lib/icons/insert-down.svelte';
|
||||
import ExtractUp from '$lib/icons/extract-up.svelte';
|
||||
import Table from '../Table.svelte';
|
||||
import Power from '$lib/icons/power.svelte';
|
||||
|
||||
const { vm, node }: { vm: VM, node: Node } = $props();
|
||||
console.log(node)
|
||||
|
||||
const drives = Object.entries(vm.config)
|
||||
.filter(([key, _]) => {
|
||||
return key.startsWith('scsi');
|
||||
})
|
||||
?.map(([key, value]) => {
|
||||
const { disk, backup, discard, iothread, size } =
|
||||
/(?<disk>[\w\:\-]+),(backup=(?<backup>[\w|\d]+))?[,]?(discard=(?<discard>[\w|\d]+))?[,]?(iothread=(?<iothread>[\w|\d]+))?[,]?(size=(?<size>[\w|\d]+)\w)?[,]?/.exec(
|
||||
value
|
||||
)?.groups || {};
|
||||
if (!disk) return;
|
||||
|
||||
return {
|
||||
device: key,
|
||||
mount: disk,
|
||||
backup: backup === '1' ? 'enabled' : 'disabled',
|
||||
size: Number(size || 1) * 1024
|
||||
};
|
||||
})
|
||||
.filter((d) => d)
|
||||
.sort((a, b) =>
|
||||
Number(a?.device.match(/\d+/)[0]) > Number(b?.device.match(/\d+/)[0]) ? 1 : -1
|
||||
);
|
||||
|
||||
const pcieDevices = Object.entries(vm.config)
|
||||
.filter(([key, _]) => {
|
||||
return key.startsWith('hostpci');
|
||||
})
|
||||
?.map(([key, value]) => {
|
||||
return {
|
||||
device: key,
|
||||
name: value
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Section title="Resources" description="">
|
||||
<div class="section-row">
|
||||
<div class="section-element">
|
||||
<label>ID</label>
|
||||
<span>{vm.vmid}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>Memory usage</label>
|
||||
<span
|
||||
><span class="icon"><FloppyDisk /></span>{formatBytes(vm.mem).match(/\d+(\.\d+)?/)?.[0]} /
|
||||
{formatBytes(vm.maxmem)}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>CPUs</label>
|
||||
<span>
|
||||
<span class="icon"><Cpu /></span>
|
||||
{vm.cpus} cores ({vm.config.sockets})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>Disk</label>
|
||||
<span>
|
||||
<span class="icon"><HardDisk /></span>
|
||||
{formatBytes(vm.maxdisk)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>Uptime</label>
|
||||
<span>
|
||||
<span class="icon"><Clock /></span>
|
||||
{formatDuration(vm.uptime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Status" description="">
|
||||
<div class="section-row">
|
||||
<div class="section-element">
|
||||
<label>State</label>
|
||||
<span>
|
||||
<span class="icon"><Power /></span>
|
||||
{vm.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>CPU load</label>
|
||||
<span>{Math.floor(vm.cpu * 10000) / 100} %</span>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>Memory usage</label>
|
||||
<span>{Math.floor((vm.mem / vm.maxmem) * 1000) / 10} %</span>
|
||||
</div>
|
||||
<div class="section-element">
|
||||
<label>Netout</label>
|
||||
<span>
|
||||
<span class="icon"><ExtractUp /></span>
|
||||
{formatBytes(vm.netout)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>Netin</label>
|
||||
<span>
|
||||
<span class="icon"><InsertDown /></span>
|
||||
{formatBytes(vm.netin)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>Disk read</label>
|
||||
<span>
|
||||
<span class="icon"><InsertDown /></span>
|
||||
{vm.diskread}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>Disk write</label>
|
||||
<span>
|
||||
<span class="icon"><InsertDown /></span>
|
||||
{vm.diskwrite}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Operating System" description="">
|
||||
<div class="section-row">
|
||||
<div class="section-element">
|
||||
<label>Name</label>
|
||||
<span>{vm.os?.name}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>Name</label>
|
||||
<span>{vm.os?.['pretty-name']}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>Version</label>
|
||||
<span>{vm.os?.version}</span>
|
||||
</div>
|
||||
|
||||
<div class="section-element">
|
||||
<label>Kernel</label>
|
||||
<span>{vm.os?.['kernel-release']}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Filesystem" description="">
|
||||
{#if drives?.length > 0}
|
||||
<Table title="FS Devices" columns={['name', 'bus', 'mountpoint', 'type', 'space', 'size']}>
|
||||
<tbody slot="tbody">
|
||||
{#each vm.fs as device (device)}
|
||||
<tr>
|
||||
<td>{device.name}</td>
|
||||
<td>
|
||||
{#each device['disk'] as device (device)}
|
||||
<span>{device?.['bus-type']}{device?.target}</span>
|
||||
{/each}
|
||||
</td>
|
||||
<td>{device.mountpoint}</td>
|
||||
<td>{device.type}</td>
|
||||
<td>{Math.floor((device['used-bytes'] / device['total-bytes']) * 10000) / 100} %</td>
|
||||
<td>{formatBytes(device['total-bytes'])}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
{/if}
|
||||
</Section>
|
||||
|
||||
<Section title="Devices" description="">
|
||||
{#if vm.network?.length > 0}
|
||||
<Table title="Network interfaces" columns={['name', 'hardward address', 'ip addresses']}>
|
||||
<tbody slot="tbody">
|
||||
{#each vm.network.sort((a,b) => a < b ? 1 : -1) as net_interface (net_interface.name)}
|
||||
<tr>
|
||||
<td>{net_interface.name}</td>
|
||||
<td>{net_interface['hardware-address']}</td>
|
||||
<td class="ip-addresses">
|
||||
{#each net_interface['ip-addresses'] as ip (ip['ip-address'])}
|
||||
<span>{ip['ip-address']}/{ip.prefix}</span>
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
{/if}
|
||||
|
||||
{#if drives?.length > 0}
|
||||
<Table title="Hard drives" columns={['device', 'mount', 'backup', 'size']}>
|
||||
<div slot="actions">
|
||||
<p>Total drives: {drives.length}</p>
|
||||
<p>Total capacity: {formatBytes(drives.reduce((acc, obj) => acc + obj.size, 0))}</p>
|
||||
</div>
|
||||
|
||||
<tbody slot="tbody">
|
||||
{#each drives as drive (drive)}
|
||||
<tr>
|
||||
<td>{drive.device}</td>
|
||||
<td>{drive.mount}</td>
|
||||
<td>{drive.backup}</td>
|
||||
<td>{formatBytes(drive.size)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
{/if}
|
||||
|
||||
{#if pcieDevices?.length > 0}
|
||||
<Table title="PCIe devices" columns={Object.keys(pcieDevices[0])}>
|
||||
<tbody slot="tbody">
|
||||
{#each pcieDevices as pcie (pcie)}
|
||||
<tr>
|
||||
<td>{pcie.device}</td>
|
||||
<td>{pcie.name}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
{/if}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
:global(article.main-container:not(:first-child)) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.section-element {
|
||||
.icon {
|
||||
display: inline-block;
|
||||
--size: 1.3rem;
|
||||
height: var(--size);
|
||||
width: var(--size);
|
||||
padding-right: 0.5rem;
|
||||
|
||||
&.spin {
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
animation: rotate 6s linear infinite;
|
||||
transform-origin: calc((var(--size) / 2) - 2px) calc(var(--size) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody .ip-addresses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,8 @@
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="768"
|
||||
height="768"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 768 768"
|
||||
>
|
||||
<g id="icomoon-ignore"> </g>
|
||||
|
||||
|
Before Width: | Height: | Size: 816 B After Width: | Height: | Size: 818 B |
@@ -1,12 +1,10 @@
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="768"
|
||||
height="768"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 768 768"
|
||||
>
|
||||
<g id="icomoon-ignore"> </g>
|
||||
<path
|
||||
d="M701.2 186.6c0 0 0 0 0 0l-288-147.6c-18.3-9.3-40.1-9.3-58.4 0l-288 147.6c-21.4 11-34.8 32.9-34.8 57v280.9c0 24.1 13.3 45.9 34.8 57l288 147.6c9.1 4.7 19.1 7 29.1 7s20.1-2.3 29.2-7l288-147.6c21.4-11 34.8-32.9 34.8-57v-280.9c0.1-24.1-13.2-45.9-34.7-57zM384 96l249.8 128-249.8 128-249.8-128 249.8-128zM96 276.4l256 131.2v248.2l-256-131.3v-248.1zM416 655.7v-248.1l256-131.2v248.2l-256 131.1z"
|
||||
></path>
|
||||
|
||||
|
Before Width: | Height: | Size: 866 B After Width: | Height: | Size: 805 B |
4
src/lib/icons/desktop.svelte
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
|
||||
<path d="M704 64h-640c-35.3 0-64 28.7-64 64v352c0 35.3 28.7 64 64 64h288v64c0 15.1-12.4 30.6-34 42.6-24.9 13.8-58.3 21.4-94 21.4v32h320v-32c-35.8 0-69.2-7.6-94-21.4-21.6-12-34-27.5-34-42.6v-64h288c35.3 0 64-28.7 64-64v-352c0-35.3-28.7-64-64-64zM423.8 672h-79.6c24.9-16.9 39.8-39.2 39.8-64 0 24.8 14.9 47.1 39.8 64zM64 128h640v288h-640v-288c-0.1 0 0 0 0 0zM64 480v-32h640v32h-640z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 556 B |
5
src/lib/icons/extract-up.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
|
||||
<path d="M656 352h-208v32h192v320h-512v-320h192v-32h-208c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h544c8.8 0 16-7.2 16-16v-352c0-8.8-7.2-16-16-16z"></path>
|
||||
<path d="M352 141.3v402.7h64v-402.7l81.4 81.4 45.3-45.3-136-136c-12.5-12.5-32.8-12.5-45.3 0l-136 136 45.3 45.3 81.3-81.4z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 455 B |
@@ -8,7 +8,6 @@
|
||||
>
|
||||
<g
|
||||
transform="translate(0.000000,108.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
>
|
||||
<path
|
||||
|
||||
|
Before Width: | Height: | Size: 896 B After Width: | Height: | Size: 879 B |
@@ -1,12 +1,10 @@
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="768"
|
||||
height="768"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 768 768"
|
||||
>
|
||||
<g id="icomoon-ignore"> </g>
|
||||
<path
|
||||
d="M672 32h-576c-35.3 0-64 28.7-64 64v498.7c0 17.1 6.7 33.1 18.8 45.2l77.3 77.3c12.1 12.1 28.2 18.8 45.2 18.8h498.7c35.3 0 64-28.7 64-64v-576c0-35.3-28.7-64-64-64zM192 64h384v256c0 17.6-14.4 32-32 32h-320c-17.6 0-32-14.4-32-32v-256zM544 704h-288v-192h288v192zM672 672h-96v-176c0-8.8-7.2-16-16-16h-320c-8.8 0-16 7.2-16 16v176h-50.7l-77.3-77.3v-498.7h64v224c0 35.3 28.7 64 64 64h320c35.3 0 64-28.7 64-64v-224h64v576z"
|
||||
></path>
|
||||
|
||||
|
Before Width: | Height: | Size: 760 B After Width: | Height: | Size: 699 B |
@@ -1,12 +1,10 @@
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="768"
|
||||
height="768"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 768 768"
|
||||
>
|
||||
<g id="icomoon-ignore"> </g>
|
||||
<path
|
||||
d="M640 0h-512c-35.3 0-64 28.7-64 64v640c0 35.3 28.7 64 64 64h512c35.3 0 64-28.7 64-64v-640c0-35.3-28.7-64-64-64zM640 704h-512v-640h512v640c0 0 0 0 0 0z"
|
||||
></path>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
5
src/lib/icons/insert-down.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
|
||||
<path d="M656 320h-208v32h192v320h-512v-320h192v-32h-208c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h544c8.8 0 16-7.2 16-16v-352c0-8.8-7.2-16-16-16z"></path>
|
||||
<path d="M270.6 417.4l-45.3 45.3 136 136c6.2 6.2 14.4 9.4 22.6 9.4s16.4-3.1 22.6-9.4l136-136-45.3-45.3-81.2 81.3v-434.7h-64v434.7l-81.4-81.3z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
5
src/lib/icons/insert-up.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
|
||||
<path d="M656 64h-544c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h208v-32h-192v-320h512v320h-192v32h208c8.8 0 16-7.2 16-16v-352c0-8.8-7.2-16-16-16z"></path>
|
||||
<path d="M497.4 350.6l45.3-45.3-136-136c-12.5-12.5-32.8-12.5-45.3 0l-136 136 45.3 45.3 81.4-81.4v434.8h64v-434.7l81.3 81.3z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 456 B |
@@ -2,8 +2,8 @@
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="768"
|
||||
height="768"
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 768 768"
|
||||
>
|
||||
<g id="icomoon-ignore"> </g>
|
||||
|
||||
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 649 B |
@@ -8,7 +8,6 @@
|
||||
>
|
||||
<g
|
||||
transform="translate(0.000000,151.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
>
|
||||
<path
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -8,7 +8,6 @@
|
||||
>
|
||||
<g
|
||||
transform="translate(0.000000,157.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
>
|
||||
<path
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -8,7 +8,6 @@
|
||||
>
|
||||
<g
|
||||
transform="translate(0.000000,151.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
>
|
||||
<path
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -8,7 +8,6 @@
|
||||
>
|
||||
<g
|
||||
transform="translate(0.000000,156.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
>
|
||||
<path
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -8,7 +8,6 @@
|
||||
>
|
||||
<g
|
||||
transform="translate(0.000000,122.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
>
|
||||
<path
|
||||
|
||||
|
Before Width: | Height: | Size: 746 B After Width: | Height: | Size: 729 B |
@@ -8,7 +8,6 @@
|
||||
>
|
||||
<g
|
||||
transform="translate(0.000000,182.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
>
|
||||
<path
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -8,7 +8,6 @@
|
||||
>
|
||||
<g
|
||||
transform="translate(0.000000,181.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000"
|
||||
stroke="none"
|
||||
>
|
||||
<path
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,5 +1,4 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 768 768">
|
||||
</g>
|
||||
<path d="M721.4 97.4l-368 368c-21.9 21.9-42.8 16.6-55.1 4.3s-17.6-33.2 4.3-55.1l368-368-45.2-45.2-368 368c-28.3 28.3-33.9 57.7-33.6 77.4 0.2 14.7 3.8 29 10.3 42l-153.5 153.4c-5.3-1.4-10.9-2.2-16.6-2.2-17.1 0-33.2 6.7-45.3 18.7-25 25-25 65.6 0 90.5 12.1 12.1 28.2 18.8 45.3 18.8s33.2-6.7 45.3-18.7c16.7-16.7 22.2-40.4 16.6-61.8l153.4-153.5c13 6.5 27.3 10.1 42 10.3 0.5 0 1 0 1.5 0 19.6 0 48.3-6 75.9-33.6l368-368-45.3-45.3zM86.6 726.6c-6 6-14.1 9.4-22.6 9.4s-16.6-3.3-22.6-9.4c-12.5-12.5-12.5-32.8 0-45.3 0 0 0 0 0 0 6-6 14.1-9.4 22.6-9.4s16.6 3.3 22.6 9.4c12.5 12.5 12.5 32.8 0 45.3z"></path>
|
||||
<path d="M448 544v32c68.4 0 132.7-26.6 181-75s75-112.6 75-181h-32c0 123.5-100.5 224-224 224z"></path>
|
||||
<path d="M224 320c0-123.5 100.5-224 224-224v-32c-68.4 0-132.7 26.6-181 75s-75 112.6-75 181h32z"></path>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
4
src/lib/icons/widescreen.svelte
Normal file
@@ -0,0 +1,4 @@
|
||||
<!-- generated by icomoon.io - licensed Lindua icon -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
|
||||
<path d="M704 64h-640c-35.3 0-64 28.7-64 64v352c0 35.3 28.7 64 64 64h288v64c0 15.1-12.4 30.6-34 42.6-24.9 13.8-58.3 21.4-94 21.4v32h320v-32c-35.8 0-69.2-7.6-94-21.4-21.6-12-34-27.5-34-42.6v-64h288c35.3 0 64-28.7 64-64v-352c0-35.3-28.7-64-64-64zM423.8 672h-79.6c24.9-16.9 39.8-39.2 39.8-64 0 24.8 14.9 47.1 39.8 64zM64 128h640v288h-640v-288c-0.1 0 0 0 0 0zM64 480v-32h640v32h-640z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 556 B |
8
src/lib/interfaces/DNS.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface Record {
|
||||
name: string;
|
||||
ttl: number;
|
||||
class: string;
|
||||
type: string;
|
||||
data: string;
|
||||
available?: boolean;
|
||||
}
|
||||
@@ -66,6 +66,45 @@ interface NodeStatus {
|
||||
bootInfo: BootInfo;
|
||||
}
|
||||
|
||||
export interface VM {
|
||||
cpu: number;
|
||||
cpus: number;
|
||||
disk: number;
|
||||
diskread: number;
|
||||
diskwrite: number;
|
||||
maxdisk: number;
|
||||
maxmem: number;
|
||||
mem: number;
|
||||
name: string;
|
||||
netin: number;
|
||||
netout: number;
|
||||
pid: number;
|
||||
status: string;
|
||||
uptime: number;
|
||||
vmid: number;
|
||||
}
|
||||
|
||||
export interface LXC {
|
||||
cpu: number;
|
||||
cpus: number;
|
||||
disk: number;
|
||||
diskread: number;
|
||||
diskwrite: number;
|
||||
maxdisk: number;
|
||||
maxmem: number;
|
||||
maxswap: number;
|
||||
mem: number;
|
||||
name: string;
|
||||
netin: number;
|
||||
netout: number;
|
||||
pid: number;
|
||||
status: string;
|
||||
swap: number;
|
||||
type: string;
|
||||
uptime: number;
|
||||
vmid: number;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
info: NodeStatus;
|
||||
online: number;
|
||||
@@ -76,4 +115,6 @@ export interface Node {
|
||||
type: string;
|
||||
ip: string;
|
||||
level: string;
|
||||
vms: Array<VM>;
|
||||
lxcs: Array<LXC>;
|
||||
}
|
||||
|
||||
@@ -57,15 +57,64 @@ async function getClusterInfo() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function vmInfo(nodeName: string, vmId: string) {
|
||||
const r = buildProxmoxRequest();
|
||||
r.url += `nodes/${nodeName}/qemu/${vmId}/config`;
|
||||
|
||||
return fetch(r.url, r?.options)
|
||||
.then(resp => resp.json())
|
||||
.then((response) => response.data)
|
||||
}
|
||||
|
||||
export async function vmCloudInit(nodeName: string, vmId: string) {
|
||||
const r = buildProxmoxRequest();
|
||||
vmId = 121
|
||||
r.url += `nodes/${nodeName}/qemu/${vmId}/cloudinit`;
|
||||
|
||||
return fetch(r.url, r?.options)
|
||||
.then(resp => resp.json())
|
||||
.then((response) => response.data)
|
||||
}
|
||||
|
||||
export async function vmAgentOS(nodeName: string, vmId: string) {
|
||||
const r = buildProxmoxRequest();
|
||||
vmId = 121
|
||||
r.url += `nodes/${nodeName}/qemu/${vmId}/agent/get-osinfo`;
|
||||
|
||||
return fetch(r.url, r?.options)
|
||||
.then(resp => resp.json())
|
||||
.then((response) => response.data?.result)
|
||||
}
|
||||
|
||||
export async function vmAgentFS(nodeName: string, vmId: string) {
|
||||
const r = buildProxmoxRequest();
|
||||
vmId = 121
|
||||
r.url += `nodes/${nodeName}/qemu/${vmId}/agent/get-fsinfo`;
|
||||
|
||||
return fetch(r.url, r?.options)
|
||||
.then(resp => resp.json())
|
||||
.then((response) => response.data?.result)
|
||||
}
|
||||
|
||||
export async function vmAgentNetwork(nodeName: string, vmId: string) {
|
||||
const r = buildProxmoxRequest();
|
||||
vmId = 121
|
||||
r.url += `nodes/${nodeName}/qemu/${vmId}/agent/network-get-interfaces`;
|
||||
|
||||
return fetch(r.url, r?.options)
|
||||
.then(resp => resp.json())
|
||||
.then((response) => response.data?.result)
|
||||
}
|
||||
|
||||
export async function fetchNodes(): Promise<{ nodes: Node[]; cluster: Cluster | null }> {
|
||||
try {
|
||||
const { nodes, cluster } = await getClusterInfo();
|
||||
|
||||
const infoP = Promise.all(nodes.map((node: Node) => fetchNodeInfo(node)));
|
||||
const vmsP = Promise.all(nodes.map((node: Node) => fetchNodeVMs(node)));
|
||||
const lxcsP = Promise.all(nodes.map((node: Node) => fetchNodeLXCs(node)));
|
||||
const infoBulk = Promise.all(nodes.map((node: Node) => fetchNodeInfo(node)));
|
||||
const vmsBulk = Promise.all(nodes.map((node: Node) => fetchNodeVMs(node)));
|
||||
const lxcsBulk = Promise.all(nodes.map((node: Node) => fetchNodeLXCs(node)));
|
||||
|
||||
const [info, vms, lxcs] = await Promise.all([infoP, vmsP, lxcsP]);
|
||||
const [info, vms, lxcs] = await Promise.all([infoBulk, vmsBulk, lxcsBulk]);
|
||||
|
||||
return {
|
||||
cluster,
|
||||
|
||||
175
src/lib/styles/card.scss
Normal file
@@ -0,0 +1,175 @@
|
||||
@keyframes pulse-live {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 var(--pulse);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(0, 212, 57, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin pulse-dot {
|
||||
&::after {
|
||||
content: '';
|
||||
top: 50%;
|
||||
margin-left: 0.4rem;
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: pulse-live 2s infinite;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
box-shadow:
|
||||
0px 0px 2px #22242714,
|
||||
0px 1px 4px #2224271f,
|
||||
0px 4px 8px #22242729;
|
||||
pointer-events: all;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
:global(body.dark .card) {
|
||||
box-shadow:
|
||||
0px 0px 2px #eaddd514,
|
||||
0px 1px 4px #eaddd515,
|
||||
0px 4px 6px #eaddd520;
|
||||
}
|
||||
|
||||
:global(body.dark .card .header, body.dark .card .footer) {
|
||||
background-color: var(--highlight);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
padding: 0.75rem;
|
||||
background-color: white;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
|
||||
.icon {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
margin-left: 0.25rem;
|
||||
opacity: 0.4;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
@include pulse-dot;
|
||||
|
||||
&.ok {
|
||||
--color: var(--positive);
|
||||
--pulse: var(--pulse-positive);
|
||||
}
|
||||
&.warning {
|
||||
--color: var(--warning);
|
||||
--pulse: var(--pulse-warning);
|
||||
}
|
||||
&.error {
|
||||
--color: var(--negative);
|
||||
--pulse: var(--pulse-negative);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0.5rem;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.resource {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg);
|
||||
|
||||
row-gap: 6px;
|
||||
max-width: 330px;
|
||||
|
||||
> div,
|
||||
span {
|
||||
display: flex;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.resource .title svg) {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
margin-top: auto;
|
||||
background: white;
|
||||
padding: 0.5rem;
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
|
||||
button {
|
||||
border: none;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
height: unset;
|
||||
border-radius: 0.5rem;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
padding: 0 0.5rem;
|
||||
flex: 1;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
margin-left: -0.5rem;
|
||||
border: 1px solid #eaddd5;
|
||||
border-radius: inherit;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.1s ease;
|
||||
will-change: box-shadow 0.25s;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #077c35;
|
||||
}
|
||||
266
src/lib/utils/dns.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import dns from 'dns';
|
||||
import net from 'net';
|
||||
|
||||
let timeout = 0;
|
||||
|
||||
const axfrReqProloge =
|
||||
'\x00\x00' + // Size
|
||||
'\x00\x00' + // Transaction ID
|
||||
'\x00\x20' + // Flags: Standard Query
|
||||
'\x00\x01' + // Number of questions
|
||||
'\x00\x00' + // Number of answers
|
||||
'\x00\x00' + // Number of Authority RRs
|
||||
'\x00\x00'; // Number of Additional RRs
|
||||
|
||||
const axfrReqEpiloge =
|
||||
'\x00' + // End of name
|
||||
'\x00\xfc' + // Type: AXFR
|
||||
'\x00\x01'; // Class: IN
|
||||
|
||||
function inet_ntoa(num: number): string {
|
||||
const nbuffer = new ArrayBuffer(4);
|
||||
const ndv = new DataView(nbuffer);
|
||||
ndv.setUint32(0, num);
|
||||
|
||||
const a: number[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
a[i] = ndv.getUint8(i);
|
||||
}
|
||||
return a.join('.');
|
||||
}
|
||||
|
||||
interface DecompressedLabel {
|
||||
len: number;
|
||||
name: string;
|
||||
next?: number;
|
||||
}
|
||||
|
||||
function decompressLabel(data: Buffer, offset: number): DecompressedLabel {
|
||||
const res: DecompressedLabel = { len: 0, name: '' };
|
||||
let loffset = offset;
|
||||
let tmpoff = 0;
|
||||
|
||||
while (data[loffset] !== 0x00) {
|
||||
// Check for pointers
|
||||
if ((data[loffset] & 0xc0) === 0xc0) {
|
||||
const newoffset = data.readUInt16BE(loffset) & 0x3fff;
|
||||
const label = decompressLabel(data, newoffset + 2);
|
||||
res.name += label.name;
|
||||
loffset += 1;
|
||||
break;
|
||||
} else {
|
||||
// Normal label
|
||||
tmpoff = loffset + 1;
|
||||
res.name += data.toString('utf8', tmpoff, tmpoff + data[loffset]);
|
||||
res.name += '.';
|
||||
loffset += data[loffset] + 1;
|
||||
}
|
||||
}
|
||||
|
||||
res.next = loffset + 1;
|
||||
return res;
|
||||
}
|
||||
|
||||
interface DNSResult {
|
||||
questions: any[];
|
||||
answers: any[];
|
||||
}
|
||||
|
||||
function parseResponse(response: Buffer, result: DNSResult): DNSResult | number {
|
||||
let offset = 14;
|
||||
let len = response.readUInt16BE(0);
|
||||
|
||||
if (response.length !== len + 2) return -1; // Invalid length
|
||||
if ((response[4] & 0x80) !== 0x80) return -2; // Not a query response
|
||||
if ((response[5] & 0x0f) !== 0) return -3; // Error code present
|
||||
|
||||
const questions = response.readUInt16BE(6);
|
||||
const answers = response.readUInt16BE(8);
|
||||
const authRRs = response.readUInt16BE(10);
|
||||
const aditRRs = response.readUInt16BE(12);
|
||||
|
||||
// Parse queries
|
||||
for (let x = 0; x < questions; x++) {
|
||||
const entry = decompressLabel(response, offset);
|
||||
result.questions.push({
|
||||
name: entry.name,
|
||||
type: 'AXFR'
|
||||
});
|
||||
offset = (entry.next ?? 0) + 4;
|
||||
}
|
||||
|
||||
// Parse answers
|
||||
for (let x = 0; x < answers; x++) {
|
||||
let entry: any = {};
|
||||
entry = decompressLabel(response, offset);
|
||||
offset = entry.next!;
|
||||
entry.name = entry.name;
|
||||
|
||||
const type = response.readUInt16BE(offset);
|
||||
const rclass = response.readUInt16BE(offset + 2);
|
||||
entry.ttl = response.readUInt32BE(offset + 4);
|
||||
const rlen = response.readUInt16BE(offset + 8);
|
||||
|
||||
// Skip non-INET
|
||||
if (rclass !== 0x01) {
|
||||
offset += rlen + 10;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 0x01: // A
|
||||
entry.type = 'A';
|
||||
entry.a = inet_ntoa(response.readUInt32BE(offset + 10));
|
||||
break;
|
||||
|
||||
case 0x02: // NS
|
||||
entry.type = 'NS';
|
||||
entry.ns = decompressLabel(response, offset + 10).name;
|
||||
break;
|
||||
|
||||
case 0x05: // CNAME
|
||||
entry.type = 'CNAME';
|
||||
entry.cname = decompressLabel(response, offset + 10).name;
|
||||
break;
|
||||
|
||||
case 0x06: {
|
||||
// SOA
|
||||
entry.type = 'SOA';
|
||||
let tentry = decompressLabel(response, offset + 10);
|
||||
entry.dns = tentry.name;
|
||||
tentry = decompressLabel(response, tentry.next!);
|
||||
entry.mail = tentry.name;
|
||||
entry.serial = response.readUInt32BE(tentry.next!);
|
||||
entry.refreshInterval = response.readUInt32BE(tentry.next! + 4);
|
||||
entry.retryInterval = response.readUInt32BE(tentry.next! + 8);
|
||||
entry.expireLimit = response.readUInt32BE(tentry.next! + 12);
|
||||
entry.minTTL = response.readUInt32BE(tentry.next! + 16);
|
||||
break;
|
||||
}
|
||||
|
||||
case 0x0f: // MX
|
||||
entry.type = 'MX';
|
||||
entry.pref = response.readUInt16BE(offset + 10);
|
||||
entry.mx = decompressLabel(response, offset + 12).name;
|
||||
break;
|
||||
|
||||
case 0x10: // TXT
|
||||
entry.type = 'TXT';
|
||||
len = response[offset + 10];
|
||||
entry.txt = response.toString('utf8', offset + 11, offset + 11 + len);
|
||||
break;
|
||||
|
||||
case 0x1c: {
|
||||
// AAAA
|
||||
entry.type = 'AAAA';
|
||||
// const byteArr = new Uint8Array(response.slice(offset + 10, offset + 26));
|
||||
// entry.aaaa = fromByteArray(byteArr).toString();
|
||||
console.warn('unable to parse AAAA DNS records');
|
||||
entry.aaaa = '::';
|
||||
break;
|
||||
}
|
||||
|
||||
case 0x63: // SPF
|
||||
entry.type = 'SPF';
|
||||
len = response[offset + 10];
|
||||
entry.txt = response.toString('utf8', offset + 11, offset + 11 + len);
|
||||
break;
|
||||
|
||||
case 0x21: // SRV
|
||||
entry.type = 'SRV';
|
||||
entry.priority = response.readUInt16BE(offset + 10);
|
||||
entry.weight = response.readUInt16BE(offset + 12);
|
||||
entry.port = response.readUInt16BE(offset + 14);
|
||||
entry.target = decompressLabel(response, offset + 16).name;
|
||||
break;
|
||||
}
|
||||
|
||||
delete entry.len;
|
||||
delete entry.next;
|
||||
result.answers.push(entry);
|
||||
offset += rlen + 10;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
(dns as any).resolveAxfrTimeout = (milis: number): void => {
|
||||
timeout = milis;
|
||||
};
|
||||
|
||||
(dns as any).resolveAxfr = (
|
||||
server: string,
|
||||
domain: string,
|
||||
callback: (code: number, result: any) => void
|
||||
): void => {
|
||||
const buffers: Buffer[] = [];
|
||||
const split = domain.split('.');
|
||||
const results: DNSResult = { questions: [], answers: [] };
|
||||
let responses: Buffer[] = [];
|
||||
let len = 0;
|
||||
let tlen = 0;
|
||||
const [hostname, portStr] = server.split(':');
|
||||
const port = Number(portStr) || 53;
|
||||
|
||||
// Build request
|
||||
buffers.push(Buffer.from(axfrReqProloge, 'binary'));
|
||||
split.forEach((elem) => {
|
||||
const label = Buffer.from('\x00' + elem, 'utf8');
|
||||
label.writeUInt8(elem.length, 0);
|
||||
buffers.push(label);
|
||||
});
|
||||
buffers.push(Buffer.from(axfrReqEpiloge, 'binary'));
|
||||
const buffer = Buffer.concat(buffers);
|
||||
|
||||
// Set size and transaction ID
|
||||
buffer.writeUInt16BE(buffer.length - 2, 0);
|
||||
buffer.writeUInt16BE(Math.floor(Math.random() * 65535 + 1), 2);
|
||||
|
||||
// Connect and send request
|
||||
const socket = net.connect(port, hostname, () => {
|
||||
socket.write(buffer.toString('binary'), 'binary');
|
||||
socket.end();
|
||||
});
|
||||
|
||||
if (timeout) socket.setTimeout(timeout);
|
||||
|
||||
socket.on('data', (data: Buffer) => {
|
||||
if (len === 0) len = data.readUInt16BE(0);
|
||||
responses.push(data);
|
||||
tlen += data.length;
|
||||
|
||||
if (tlen >= len + 2) {
|
||||
const buf = Buffer.concat(responses, tlen);
|
||||
const tmpBuf = buf.slice(0, len + 2);
|
||||
const res = parseResponse(tmpBuf, results);
|
||||
|
||||
if (typeof res !== 'object') {
|
||||
socket.destroy();
|
||||
callback(res, 'Error on response');
|
||||
}
|
||||
}
|
||||
|
||||
if (tlen > len + 2) {
|
||||
const buf = Buffer.concat(responses, tlen);
|
||||
const tmpBuf = buf.slice(len + 2);
|
||||
len = tmpBuf.readUInt16BE(0);
|
||||
tlen = tmpBuf.length;
|
||||
responses = [tmpBuf];
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('timeout', () => {
|
||||
socket.destroy();
|
||||
callback(-5, 'Timeout');
|
||||
});
|
||||
|
||||
socket.on('end', () => {
|
||||
callback(0, results);
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
callback(-4, 'Error connecting');
|
||||
});
|
||||
};
|
||||
|
||||
export default dns;
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import DarkmodeToggle from '$lib/components/DarkmodeToggle.svelte';
|
||||
import GlobalSearch from '$lib/components/GlobalSearch.svelte';
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
@@ -13,6 +15,9 @@
|
||||
<slot></slot>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<DarkmodeToggle />
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import PageElement from '$lib/components/PageElement.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let elems = [];
|
||||
let counter = 0;
|
||||
@@ -28,10 +29,10 @@
|
||||
return {
|
||||
bgColor: colors[counter - 1][0],
|
||||
color: colors[counter - 1][1],
|
||||
title,
|
||||
name: title,
|
||||
header: null,
|
||||
description: description ? description : '',
|
||||
link: title
|
||||
link: `/${title}`
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,11 +60,13 @@
|
||||
|
||||
elems = elems.concat(createPageElement('cluster '));
|
||||
elems = elems.concat(createPageElement('health '));
|
||||
|
||||
onMount(() => window.elements = elems)
|
||||
</script>
|
||||
|
||||
<PageHeader>Welcome to schleppe.cloud infra overview</PageHeader>
|
||||
|
||||
<p>
|
||||
<p class="site-desc">
|
||||
This site is a local-first dashboard for monitoring the state of digital and physical tools in a
|
||||
workshop environment. It currently tracks servers (IP, cores, memory, uptime), 3D printers
|
||||
(status, history, filament stock), and other connected devices. Each device or system has its own
|
||||
@@ -71,7 +74,7 @@
|
||||
general monitoring tools, IoT integrations, and project overviews.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<p class="site-desc">
|
||||
The system is intended for hybrid spaces where digital infrastructure coexists with hands-on work.
|
||||
Alongside real-time monitoring, Schleppe is expanding to reflect the broader physical
|
||||
workspace—covering areas like tool usage, material stocks, and workstations for welding,
|
||||
@@ -80,11 +83,11 @@
|
||||
</p>
|
||||
|
||||
<div class="shortcut-grid">
|
||||
{#each elems as shortcut (shortcut.title)}
|
||||
{#each elems as shortcut (shortcut.name)}
|
||||
<PageElement
|
||||
bgColor={shortcut.bgColor}
|
||||
color={shortcut.color}
|
||||
title={shortcut.title}
|
||||
title={shortcut.name}
|
||||
header={shortcut.header}
|
||||
description={shortcut.description}
|
||||
link={shortcut.link}
|
||||
@@ -93,18 +96,23 @@
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
p {
|
||||
p.site-desc {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.4;
|
||||
line-height: 1.7;
|
||||
color: #333;
|
||||
|
||||
background-color: #fafafa; /* Subtle background to separate it from the rest */
|
||||
background-color: #fafafa;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem; /* Soft edges */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); /* Light shadow for depth */
|
||||
}
|
||||
|
||||
:global(body.dark p.site-desc) {
|
||||
background-color: var(--highlight);
|
||||
color: var(--color);
|
||||
}
|
||||
|
||||
.shortcut-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Node from '$lib/components/Node.svelte';
|
||||
import Deploy from '$lib/components/Deploy.svelte';
|
||||
import Daemon from '$lib/components/Daemon.svelte';
|
||||
@@ -14,10 +15,22 @@
|
||||
const rawDaemons: V1DaemonSet[] = data?.daemons;
|
||||
const rawNodes: V1Node[] = data?.nodes;
|
||||
|
||||
let filterLC = $derived(filterValue.toLowerCase())
|
||||
let filterLC = $derived(filterValue.toLowerCase());
|
||||
let deployments = $derived(rawDeployments.filter((d) => d.metadata.name.includes(filterLC)));
|
||||
let daemons = $derived(rawDaemons.filter((d) => d.metadata.name.includes(filterLC)));
|
||||
let nodes = $derived(rawNodes.filter((n) => n.metadata.name.includes(filterLC)));
|
||||
|
||||
onMount(() => {
|
||||
window.elements = deployments
|
||||
.map((d) => {
|
||||
return {
|
||||
name: d.metadata?.name || undefined,
|
||||
link: `/cluster/deployment/${d.metadata?.uid}`,
|
||||
...d
|
||||
};
|
||||
})
|
||||
.filter((d) => d.name);
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader>Cluster overview</PageHeader>
|
||||
|
||||
63
src/routes/dns/+page.server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { exec } from 'child_process';
|
||||
import dns from '$lib/utils/dns';
|
||||
import type { Record } from '$lib/interfaces/DNS';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// Regex for IPv4 validation
|
||||
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$/;
|
||||
|
||||
function ping(data: string): Promise<boolean> {
|
||||
if (!ipv4Regex.test(data)) return;
|
||||
const ip = data;
|
||||
|
||||
console.log(`Starting ping for ip: ${ip}`);
|
||||
const resolver = '10.0.0.72';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(
|
||||
`ping -c 1 -t 1.5 -W 1 ${ip} > /dev/null 2>&1 && exit 0 || exit 1`,
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`❌ Exec error: ${error.message}`);
|
||||
reject(error);
|
||||
}
|
||||
if (stderr) {
|
||||
console.error(`⚠️ Stderr: ${stderr}`);
|
||||
reject(error);
|
||||
}
|
||||
|
||||
console.log('✅ Ping response received:', stdout);
|
||||
resolve(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function axfrAsync(server: string, domain: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
dns.resolveAxfr(server, domain, (err, resp) => {
|
||||
if (err) reject(err);
|
||||
resolve(resp?.answers);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const server = '10.0.0.72';
|
||||
const domain = 'schleppe';
|
||||
const records = await axfrAsync(server, domain);
|
||||
|
||||
// console.log('records::', records);
|
||||
const ARecords = records?.filter((r) => r?.type === 'A');
|
||||
|
||||
for (let i = 0; i < ARecords.length; i++) {
|
||||
try {
|
||||
// const a = await ping(ARecords[i].a);
|
||||
records[i]['available'] = true;
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
return {
|
||||
records
|
||||
};
|
||||
};
|
||||
163
src/routes/dns/+page.svelte
Normal file
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import Search from '$lib/icons/search.svelte';
|
||||
import Input from '$lib/components/Input.svelte';
|
||||
import Table from '$lib/components/Table.svelte';
|
||||
import Dialog from '$lib/components/Dialog.svelte';
|
||||
import FormDNS from '$lib/components/forms/FormDNS.svelte';
|
||||
import type { Record } from '$lib/interfaces/DNS';
|
||||
import type { PageData } from '../$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let recordsFilter = $state('');
|
||||
let recordsSort = $state('type');
|
||||
let open = $state(false);
|
||||
|
||||
const rawRecords: Record[] = data?.records || [];
|
||||
let records = $derived(
|
||||
rawRecords
|
||||
?.filter(
|
||||
(r: Record) =>
|
||||
r.name?.toLowerCase()?.includes(recordsFilter) ||
|
||||
r.data?.toLowerCase()?.includes(recordsFilter)
|
||||
)
|
||||
.sort((a, b) => (a?.[recordsSort] < b?.[recordsSort] ? -1 : 1))
|
||||
);
|
||||
</script>
|
||||
|
||||
<h1>DNS</h1>
|
||||
|
||||
<Table
|
||||
title="Zonal records"
|
||||
description="schleppe colors are currently in stock. Overview of currently stocked filament."
|
||||
columns={['Ping', 'Details', 'TTL']}
|
||||
data={records}
|
||||
footer="Last updated on ~some date~"
|
||||
>
|
||||
<div slot="actions" class="filament-table-inputs">
|
||||
<div>
|
||||
<Input
|
||||
placeholder={`${records?.[0]?.name || 'record'}`}
|
||||
icon={Search}
|
||||
bind:value={recordsFilter}
|
||||
label="Records filter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="affirmative" on:click={() => (open = true)}><span>Add new</span></button>
|
||||
</div>
|
||||
|
||||
<tbody slot="tbody">
|
||||
{#each records as row (row)}
|
||||
<tr>
|
||||
<td>{row?.available === true ? 'up' : 'down'}</td>
|
||||
<td class="info">
|
||||
<div class="meta">
|
||||
<span>name: {row.name}</span>
|
||||
<span>type: {row.type}</span>
|
||||
<span>addr: {row.a}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{row.ttl}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
{#if open}
|
||||
<Dialog
|
||||
title="Add DNS record"
|
||||
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
|
||||
close={() => (open = false)}
|
||||
>
|
||||
<FormDNS close={() => (open = false)} />
|
||||
</Dialog>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.section-element {
|
||||
.icon {
|
||||
display: inline-block;
|
||||
--size: 2rem;
|
||||
height: var(--size);
|
||||
width: var(--size);
|
||||
padding-right: 0.5rem;
|
||||
|
||||
&.spin {
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
animation: rotate 6s linear infinite;
|
||||
transform-origin: calc((var(--size) / 2) - 2px) calc(var(--size) / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
span {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.filament-table-inputs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
> div {
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
> button {
|
||||
flex: unset;
|
||||
height: 2.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* filament table */
|
||||
tr td {
|
||||
&:first-of-type {
|
||||
}
|
||||
|
||||
&.info {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
|
||||
h2 {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-bottom: 0.45em;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 300;
|
||||
color: var(--theme);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex-direction: column;
|
||||
opacity: 0.6;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
.color {
|
||||
--size: 4rem;
|
||||
display: block;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: var(--border-radius, 1rem);
|
||||
}
|
||||
</style>
|
||||
@@ -22,7 +22,7 @@
|
||||
columns={['Domain', 'SSL', 'Status', 'Code']}
|
||||
>
|
||||
<tbody slot="tbody">
|
||||
{#each httpHealth as row, i (row)}
|
||||
{#each httpHealth as row (row)}
|
||||
<tr>
|
||||
<td>{row.domain}</td>
|
||||
<td>
|
||||
@@ -48,7 +48,7 @@
|
||||
</Table>
|
||||
|
||||
{#if selectedSSL !== null}
|
||||
<Dialog on:close={() => (selectedSSL = null)} title="SSL Certificate info">
|
||||
<Dialog close={() => (selectedSSL = null)} title="SSL Certificate info">
|
||||
<JsonViewer json={selectedSSL} />
|
||||
</Dialog>
|
||||
{/if}
|
||||
|
||||
@@ -247,7 +247,7 @@
|
||||
</div>
|
||||
|
||||
<tbody slot="tbody">
|
||||
{#each filament as row, i (row)}
|
||||
{#each filament as row (row)}
|
||||
<tr class="link" on:click={() => goto(filamentLink(row))}>
|
||||
<td><span class="color" style={`background: ${row.hex}`} /></td>
|
||||
<td class="info">
|
||||
@@ -268,7 +268,7 @@
|
||||
|
||||
{#if open}
|
||||
<Dialog
|
||||
on:close={() => (open = false)}
|
||||
close={() => (open = false)}
|
||||
title="Add new filament"
|
||||
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
|
||||
>
|
||||
@@ -335,7 +335,6 @@
|
||||
&.info {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
padding-left: 25px;
|
||||
|
||||
h2 {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import ServerComp from '$lib/components/Server.svelte';
|
||||
import ServerSummary from '$lib/components/ServerSummary.svelte';
|
||||
import VMComp from '$lib/components/VM.svelte';
|
||||
import LXCComp from '$lib/components/LXC.svelte';
|
||||
import type { VM, LXC } from '$lib/components/VM.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const { cluster, nodes } = data;
|
||||
const allVms: Array<VM> = nodes.flatMap((n) => n.vms).filter((v) => v.template !== 1);
|
||||
const allLxcs: Array<LXC> = nodes.flatMap((n) => n.lxcs);
|
||||
|
||||
onMount(() => {
|
||||
window.elements = [
|
||||
...allVms
|
||||
.map((vm) => {
|
||||
return {
|
||||
link: `/servers/vm/${vm.vmid}`,
|
||||
...vm
|
||||
};
|
||||
})
|
||||
.filter((d) => d.name),
|
||||
...nodes.map((node) => {
|
||||
return {
|
||||
link: `/servers/node/${node.name}`,
|
||||
...node
|
||||
};
|
||||
}),
|
||||
...allLxcs.map((lxc) => {
|
||||
return {
|
||||
link: `/servers/lxc/${lxc.vmid}`,
|
||||
...lxc
|
||||
};
|
||||
})
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader>Servers</PageHeader>
|
||||
@@ -15,18 +46,63 @@
|
||||
|
||||
<div class="server-list">
|
||||
{#each nodes as node (node.name)}
|
||||
<div>
|
||||
<ServerComp {node} />
|
||||
</div>
|
||||
<ServerComp {node} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<details open>
|
||||
<summary>
|
||||
<h2>VMs</h2>
|
||||
</summary>
|
||||
|
||||
<div class="vm-list">
|
||||
{#each allVms as vm (vm.vmid)}
|
||||
<VMComp {vm} />
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>
|
||||
<h2>LXCs</h2>
|
||||
</summary>
|
||||
|
||||
<div class="vm-list">
|
||||
{#each allLxcs as lxc (lxc)}
|
||||
<LXCComp {lxc} />
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<style lang="scss">
|
||||
.server-list {
|
||||
*:not(:last-child) {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.server-list,
|
||||
.vm-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: left;
|
||||
gap: 2rem;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
details summary::-webkit-details-marker,
|
||||
details summary::marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
details > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
82
src/routes/servers/[kind]/[id]/+page.server.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
fetchNodes,
|
||||
vmInfo,
|
||||
vmCloudInit,
|
||||
vmAgentOS,
|
||||
vmAgentFS,
|
||||
vmAgentNetwork
|
||||
} from '$lib/server/proxmox';
|
||||
import type { Node, VM } from '$lib/interfaces/proxmox';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const AVAILABLE_RESOURCES = ['node', 'vm', 'lxc'];
|
||||
|
||||
const filterResources = (resource: Node | VM, id) => {
|
||||
if (resource?.name && resource.name === id) {
|
||||
return resource;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const { kind, id } = params;
|
||||
console.log('KIND', kind);
|
||||
|
||||
if (!AVAILABLE_RESOURCES.includes(kind)) {
|
||||
return {
|
||||
error: 'No resource ' + kind,
|
||||
resource: null
|
||||
};
|
||||
}
|
||||
console.log(params.id);
|
||||
|
||||
const cluster = await fetchNodes();
|
||||
let vm = [];
|
||||
let nodeId;
|
||||
const resources: Array<Node | VM> = [];
|
||||
|
||||
switch (kind) {
|
||||
case 'node':
|
||||
vm = cluster?.nodes.find((n) => n.name === id) || resources;
|
||||
break;
|
||||
case 'vm':
|
||||
nodeId = cluster.nodes.find(
|
||||
(n) => n.vms.filter((vm) => String(vm.vmid) === id)?.length > 0
|
||||
)?.name;
|
||||
const clusterVM = cluster.nodes.flatMap((n) => n.vms)?.find((vm) => String(vm.vmid) === id);
|
||||
|
||||
if (!nodeId || !clusterVM.vmid) return;
|
||||
const [cloudInit, os, fs, network] = await Promise.all([
|
||||
vmCloudInit(nodeId, clusterVM.vmid),
|
||||
vmAgentOS(nodeId, clusterVM.vmid),
|
||||
vmAgentFS(nodeId, clusterVM.vmid),
|
||||
vmAgentNetwork(nodeId, clusterVM.vmid)
|
||||
]);
|
||||
vm = {
|
||||
config: await vmInfo(nodeId, clusterVM.vmid),
|
||||
cloudInit,
|
||||
os,
|
||||
fs,
|
||||
network,
|
||||
...clusterVM
|
||||
};
|
||||
// resources = [vm]
|
||||
break;
|
||||
case 'lxc':
|
||||
vm = cluster.nodes.flatMap((n) => n.lxcs)?.find((lxc) => String(lxc.vmid) === id);
|
||||
|
||||
break;
|
||||
default:
|
||||
console.log('no resources found');
|
||||
}
|
||||
|
||||
// console.log('returning', vm);
|
||||
|
||||
return {
|
||||
resource: vm,
|
||||
kind: params.kind,
|
||||
id: params.id,
|
||||
error: null
|
||||
};
|
||||
};
|
||||
53
src/routes/servers/[kind]/[id]/+page.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import JsonViewer from '$lib/components/JsonViewer.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import VMDescribe from '$lib/components/prox-describe/VM.svelte';
|
||||
import External from '$lib/icons/external.svelte';
|
||||
import Link from '$lib/icons/link.svelte';
|
||||
import type { Node, VM } from '$lib/interfaces/proxmox';
|
||||
import type { PageData, PageProps } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const { error, kind } = data;
|
||||
const { resource }: { vm: VM; node: Node } = data;
|
||||
console.log('RESOURCE', resource);
|
||||
|
||||
let res = $state(data.kind);
|
||||
const template = `https://apollo.schleppe:8006/#v1:0:=qemu%2F${resource?.vmid}:4:::::::`
|
||||
console.log('kind res:', res);
|
||||
</script>
|
||||
|
||||
<PageHeader>{kind || 'Resource'}: {resource?.name || 'not found'}</PageHeader>
|
||||
|
||||
{#if error}
|
||||
<p>{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if resource}
|
||||
<div class="title">
|
||||
<a href={template}>View in proxmox</a>
|
||||
<span class="link"><External /></span>
|
||||
</div>
|
||||
|
||||
{#if kind == 'vm'}
|
||||
<VMDescribe vm={resource} />
|
||||
{:else}
|
||||
<p>{kind} stuffs</p>
|
||||
<JsonViewer json={resource} />
|
||||
{/if}
|
||||
{:else}
|
||||
<h2>404. '{kind}' resource not found!</h2>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.title {
|
||||
font-size: 1.1rem;
|
||||
font-family: 'Reckless Neue';
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-block;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -5,15 +5,17 @@
|
||||
import FormSite from '$lib/components/forms/FormSite.svelte';
|
||||
import ThumbnailButton from '$lib/components/ThumbnailButton.svelte';
|
||||
import type { Site } from '$lib/interfaces/site.ts';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { data }: { data: { site: Site } } = $props();
|
||||
let open = $state(false);
|
||||
|
||||
const { sites } = data;
|
||||
|
||||
onMount(() => window.elements = sites)
|
||||
</script>
|
||||
|
||||
<PageHeader>Sites
|
||||
|
||||
<button class="add-site-btn affirmative" on:click={() => (open = true)}><span>Add new site</span></button>
|
||||
</PageHeader>
|
||||
|
||||
@@ -31,7 +33,7 @@
|
||||
|
||||
{#if open}
|
||||
<Dialog
|
||||
on:close={() => (open = false)}
|
||||
close={() => (open = false)}
|
||||
title="Add new site"
|
||||
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
|
||||
>
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
:root,
|
||||
.light {
|
||||
color-scheme: light;
|
||||
--bg: #f9f5f3;
|
||||
--color: #1c1819;
|
||||
--highlight: #eaddd5;
|
||||
@@ -37,9 +39,63 @@
|
||||
--positive: #00d439;
|
||||
--negative: #ff5449;
|
||||
--warning: #ffa312;
|
||||
--pulse-positive: rgba(0, 212, 57, 0.7);
|
||||
--pulse-negative: rgba(255, 84, 73, 0.7);
|
||||
--pulse-warning: rgba(255, 163, 18, 0.7);
|
||||
|
||||
--border: 1px solid #eaddd5;
|
||||
--border-radius: 0.75rem;
|
||||
|
||||
--card-bg: rgba(255, 255, 255, 0.7);
|
||||
--key: #6e80d6;
|
||||
--muted: #7a6d6e;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #121010;
|
||||
--color: #f4eeeb;
|
||||
--highlight: #2b2325;
|
||||
--theme: #eaddd5;
|
||||
|
||||
--positive: #3ddc84;
|
||||
--negative: #ff6b63;
|
||||
--warning: #ffb74d;
|
||||
--pulse-positive: rgba(61, 220, 132, 0.6);
|
||||
--pulse-negative: rgba(255, 107, 99, 0.6);
|
||||
--pulse-warning: rgba(255, 183, 77, 0.6);
|
||||
|
||||
--border: 1px solid #2b2325;
|
||||
|
||||
--key: #9fb3ff;
|
||||
--muted: #b0b0b0;
|
||||
--card-bg: #303037;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark {
|
||||
--bg: #121010;
|
||||
--color: #f4eeeb;
|
||||
--highlight: #2b2325;
|
||||
--theme: #eaddd5;
|
||||
|
||||
--positive: #3ddc84;
|
||||
--negative: #ff6b63;
|
||||
--warning: #ffb74d;
|
||||
--pulse-positive: rgba(61, 220, 132, 0.6);
|
||||
--pulse-negative: rgba(255, 107, 99, 0.6);
|
||||
--pulse-warning: rgba(255, 183, 77, 0.6);
|
||||
|
||||
--border: 1px solid #2b2325;
|
||||
|
||||
--key: #9fb3ff;
|
||||
--muted: #b0b0b0;
|
||||
--card-bg: #303037;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: var(--color);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -51,6 +107,9 @@ body {
|
||||
background-color: var(--bg);
|
||||
color: var(--color);
|
||||
font-size: 14px;
|
||||
transition:
|
||||
background 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
|
||||
a,
|
||||
@@ -96,6 +155,7 @@ button {
|
||||
border: none;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
color: var(--color);
|
||||
height: 100%;
|
||||
border-radius: 0.5rem;
|
||||
display: inline-block;
|
||||
@@ -134,6 +194,21 @@ button.affirmative:hover span {
|
||||
background-color: var(--highlight);
|
||||
}
|
||||
|
||||
/* dark mode button overrides */
|
||||
body.dark button span {
|
||||
background-color: var(--theme);
|
||||
color: var(--bg);
|
||||
}
|
||||
body.dark button.affirmative span {
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
body.dark button.affirmative:hover span {
|
||||
color: var(--color);
|
||||
background-color: var(--highlight);
|
||||
border-color: var(--bg);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -191,7 +266,7 @@ table tr:not(table tr:last-of-type) {
|
||||
|
||||
table tr:hover > td {
|
||||
background-color: var(--highlight);
|
||||
background-color: #f5ede9;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
table tr.link {
|
||||
@@ -204,6 +279,14 @@ table tr.link {
|
||||
border: var(--border);
|
||||
border-radius: var(--border-radius, 1rem);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark .main-container {
|
||||
background: var(--highlight) !important;
|
||||
}
|
||||
|
||||
.section-wrapper {
|
||||
|
||||