10 Commits

53 changed files with 2185 additions and 513 deletions

View 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>

View File

@@ -1,12 +1,17 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { clickOutside } from '$lib/utils/mouseEvents'; import { clickOutside } from '$lib/utils/mouseEvents';
export let title: string; interface Props {
export let description: string | null = null; title: string;
description: string | null;
const dispatch = createEventDispatcher(); close(): void
const close = () => dispatch('close'); }
const {
title,
description,
close
}: Props = $props()
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -40,23 +45,25 @@
class="dialog" class="dialog"
> >
<div tabindex="-1" id="dialog-title" class="title"> <div tabindex="-1" id="dialog-title" class="title">
<header> {#if title.length || description?.length}
<button on:click={close} aria-disabled="false" aria-label="Close" type="button" tabindex="0" <header>
><svg viewBox="0 0 24 24" aria-hidden="true" tabindex="-1" height="100%" width="100%" <button on:click={close} aria-disabled="false" aria-label="Close" type="button" tabindex="0"
><path ><svg viewBox="0 0 24 24" aria-hidden="true" tabindex="-1" height="100%" width="100%"
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
></path></svg 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> </button>
</header> <h5>{title}</h5>
</header>
{/if}
<main> <main>
<div id="dialog-description"> {#if description}
{#if description} <div id="dialog-description">
{@html description} {@html description}
{/if} </div>
</div> {/if}
<!-- <!--
<div class="alerts"> <div class="alerts">
@@ -83,6 +90,8 @@
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
position: fixed; position: fixed;
--offset-top: 4rem;
padding-top: var(--offset-top);
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
@@ -96,18 +105,26 @@
visibility 0.4s ease; visibility 0.4s ease;
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
align-items: center;
@media screen and (max-width: 480px) {
padding: 1rem;
width: calc(100vw - 2rem);
height: calc(100vh - 2rem);
}
> div { > div {
max-width: 880px; max-width: 880px;
max-width: unset; // max-width: unset;
} }
} }
.title { .title {
--padding: 1rem; --padding: 1rem;
--background-color: #ffffff;
--text-color: black;
position: relative; position: relative;
background-color: #ffffff; background-color: var(--background-color);
color: var(--text-color);
background-clip: padding-box; background-clip: padding-box;
border-radius: 12px; border-radius: 12px;
display: flex; display: flex;
@@ -123,7 +140,6 @@
0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 9px 28px 8px rgba(0, 0, 0, 0.05); 0 9px 28px 8px rgba(0, 0, 0, 0.05);
pointer-events: auto; pointer-events: auto;
max-height: 90vh;
padding: var(--padding); padding: var(--padding);
width: calc(880px - calc(--padding * 2)); width: calc(880px - calc(--padding * 2));
z-index: 2008; z-index: 2008;

View File

@@ -4,10 +4,10 @@
import { clickOutside } from '$lib/utils/mouseEvents'; import { clickOutside } from '$lib/utils/mouseEvents';
export let options = ['Today', 'Yesterday', 'Last 7 Days', 'Last 30 Days', 'All time']; 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 placeholder = '';
export let label = ''; export let label = '';
export let icon = undefined; export let icon: unknown = undefined;
export let required = false; export let required = false;
let dropdown: Element; let dropdown: Element;
@@ -29,9 +29,7 @@
} }
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
console.log('dropdown element:', dropdown);
const outside = clickOutside(event, dropdown); const outside = clickOutside(event, dropdown);
console.log('click outside:', outside);
if (outside === false) { if (outside === false) {
return; return;
} }

View 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>

View File

@@ -46,7 +46,7 @@
background: var(--theme); background: var(--theme);
padding: 0 1rem; padding: 0 1rem;
border-radius: 6px; border-radius: 6px;
color: white; color: var(--bg);
margin: 1rem 0.5rem 0 0.5rem; margin: 1rem 0.5rem 0 0.5rem;
font-weight: 400; font-weight: 400;
font-size: 1rem; font-size: 1rem;
@@ -66,7 +66,7 @@
font-size: 1.5rem; font-size: 1.5rem;
padding: 0; padding: 0;
font-weight: 300; font-weight: 300;
color: white !important; color: var(--bg) !important;
} }
img { img {

View File

@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
export let label: string; export let label: string;
export let value: string;
export let placeholder: string; export let placeholder: string;
export let value: string = '';
export let required = false; export let required = false;
export let icon: unknown; export let icon: unknown = null;
let focus = false; let focus = false;
</script> </script>
@@ -33,6 +33,10 @@
</div> </div>
<style lang="scss"> <style lang="scss">
:global(body.dark .label-input .input) {
background: var(--highlight);
}
.label-input { .label-input {
width: 100%; width: 100%;
@@ -54,8 +58,9 @@
.input { .input {
position: relative; position: relative;
display: flex; display: flex;
--padding: 0.75rem; --padding-h: 0.25rem;
width: calc(100% - (var(--padding) * 2)); --padding-w: 0.75rem;
width: calc(100% - (var(--padding-w) * 2));
height: 2.5rem; height: 2.5rem;
background: #ffffff; background: #ffffff;
align-items: center; align-items: center;
@@ -66,7 +71,7 @@
outline: none; outline: none;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0px var(--padding); padding: var(--padding-h) var(--padding-w);
&.focus { &.focus {
box-shadow: 0px 0px 0px 4px #7d66654d; box-shadow: 0px 0px 0px 4px #7d66654d;

View 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>

View File

@@ -21,8 +21,8 @@
imageSource = reader?.result || ''; imageSource = reader?.result || '';
if (imageSource === '') { if (imageSource === '') {
console.log("no image data, returning") console.log('no image data, returning');
return return;
} }
// set imageSource to image element // set imageSource to image element
@@ -78,27 +78,34 @@
}); });
</script> </script>
<div> <div class="liveimage">
{#if !fullscreen} {#if !fullscreen}
<img on:click={() => (fullscreen = !fullscreen)} src={String(imageSource)} id="live-image" /> <img on:click={() => (fullscreen = !fullscreen)} src={String(imageSource)} id="live-image" />
{:else} {:else}
<Dialog title="Live stream of printer" on:close={() => (fullscreen = false)}> <div class="fullscreen-container">
<img style="width: 100%;" src={String(imageSource)} id="live-image" /> <Dialog title="Live stream of printer" close={() => (fullscreen = false)}>
<span>Last update {timestamp}s ago</span> <img style="width: 100%;" src={String(imageSource)} id="live-image" />
</Dialog> <span>Last update {timestamp}s ago</span>
</Dialog>
<img src={String(grey400x225)} /> <img src={String(grey400x225)} />
</div>
{/if} {/if}
<span>Last update {timestamp}s ago</span> <span>Last update {timestamp}s ago</span>
</div> </div>
<style lang="scss"> <style lang="scss">
img { .liveimage img {
width: 400px; width: 100%;
max-width: 400px;
border-radius: 0.5rem; border-radius: 0.5rem;
} }
span { span {
display: block; display: block;
} }
:global(.fullscreen-container .dialog > div) {
max-width: unset;
}
</style> </style>

View File

@@ -69,133 +69,5 @@
</div> </div>
<style lang="scss"> <style lang="scss">
.card { @import "../styles/card.scss";
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;
}
</style> </style>

View File

@@ -105,6 +105,8 @@
</div> </div>
<style lang="scss"> <style lang="scss">
@import "../styles/card.scss";
.card-container { .card-container {
background-color: #cab2aa40; background-color: #cab2aa40;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -122,99 +124,4 @@
gap: 2rem; 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> </style>

View File

@@ -10,7 +10,9 @@
<h2>{title}</h2> <h2>{title}</h2>
<slot name="top-left" /> <slot name="top-left" />
</div> </div>
<label>{description}</label> {#if description && description?.length > 0}
<label>{description}</label>
{/if}
</div> </div>
{/if} {/if}

View File

@@ -24,7 +24,7 @@
link: `https://${node.ip}:8006/#v1:0:=node%2F${node.name}:4:=jsconsole::::::` 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: '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; let { cpuinfo, memory, uptime, loadavg } = node.info;
@@ -116,7 +116,7 @@
<div class="footer"> <div class="footer">
{#each buttons as btn (btn)} {#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> <button>
<span>{btn.name}</span> <span>{btn.name}</span>
</button> </button>
@@ -126,163 +126,5 @@
</div> </div>
<style lang="scss"> <style lang="scss">
@keyframes pulse-live { @import '../styles/card.scss';
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;
}
</style> </style>

View File

@@ -1,10 +1,4 @@
<script lang="ts"> <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 title = '';
export let description = ''; export let description = '';
export let columns: Array<string> | object; export let columns: Array<string> | object;
@@ -12,7 +6,6 @@
export let links: Array<string> = []; export let links: Array<string> = [];
export let footer = ''; export let footer = '';
const hasLinks = links?.length > 0;
let displayColumns: string[] = []; let displayColumns: string[] = [];
if (typeof columns === 'object' && !Array.isArray(columns)) { if (typeof columns === 'object' && !Array.isArray(columns)) {
displayColumns = Object.values(columns); displayColumns = Object.values(columns);
@@ -21,13 +14,17 @@
</script> </script>
<div class="main-container"> <div class="main-container">
<div class="header"> {#if title?.length || description?.length}
<h2>{title}</h2> <div class="header">
<div class="description">{description}</div> <h2>{title}</h2>
</div> <div class="description">{description}</div>
</div>
{/if}
<div class="actions"> <div class="actions">
<slot name="actions"></slot> <slot name="actions"></slot>
</div> </div>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -58,7 +55,7 @@
.description { .description {
font-size: 0.875rem; font-size: 0.875rem;
color: #666; opacity: 0.6;
margin-bottom: 12px; margin-bottom: 12px;
} }

View 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>

View 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>

View File

@@ -15,22 +15,6 @@
</div> </div>
<style lang="scss"> <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 { .tab {
&:not(&:first-of-type) { &:not(&:first-of-type) {
margin-left: 0.75rem; margin-left: 0.75rem;
@@ -40,10 +24,21 @@
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; background: none;
opacity: 0.7; opacity: 0.6;
margin: 0;
padding-bottom: 0.3rem; 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> </style>

View 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>

View File

@@ -2,8 +2,8 @@
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="768" width="100%"
height="768" height="100%"
viewBox="0 0 768 768" viewBox="0 0 768 768"
> >
<g id="icomoon-ignore"> </g> <g id="icomoon-ignore"> </g>

Before

Width:  |  Height:  |  Size: 816 B

After

Width:  |  Height:  |  Size: 818 B

View File

@@ -1,12 +1,10 @@
<!-- Generated by IcoMoon.io -->
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="768" width="100%"
height="768" height="100%"
viewBox="0 0 768 768" viewBox="0 0 768 768"
> >
<g id="icomoon-ignore"> </g>
<path <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" 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> ></path>

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 805 B

View 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

View 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

View File

@@ -8,7 +8,6 @@
> >
<g <g
transform="translate(0.000000,108.000000) scale(0.100000,-0.100000)" transform="translate(0.000000,108.000000) scale(0.100000,-0.100000)"
fill="#000000"
stroke="none" stroke="none"
> >
<path <path

Before

Width:  |  Height:  |  Size: 896 B

After

Width:  |  Height:  |  Size: 879 B

View File

@@ -1,12 +1,10 @@
<!-- Generated by IcoMoon.io -->
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="768" width="100%"
height="768" height="100%"
viewBox="0 0 768 768" viewBox="0 0 768 768"
> >
<g id="icomoon-ignore"> </g>
<path <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" 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> ></path>

Before

Width:  |  Height:  |  Size: 760 B

After

Width:  |  Height:  |  Size: 699 B

View File

@@ -1,12 +1,10 @@
<!-- Generated by IcoMoon.io -->
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="768" width="100%"
height="768" height="100%"
viewBox="0 0 768 768" viewBox="0 0 768 768"
> >
<g id="icomoon-ignore"> </g>
<path <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" 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> ></path>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View 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

View 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

View File

@@ -2,8 +2,8 @@
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="768" width="100%"
height="768" height="100%"
viewBox="0 0 768 768" viewBox="0 0 768 768"
> >
<g id="icomoon-ignore"> </g> <g id="icomoon-ignore"> </g>

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 649 B

View File

@@ -8,7 +8,6 @@
> >
<g <g
transform="translate(0.000000,151.000000) scale(0.100000,-0.100000)" transform="translate(0.000000,151.000000) scale(0.100000,-0.100000)"
fill="#000000"
stroke="none" stroke="none"
> >
<path <path

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -8,7 +8,6 @@
> >
<g <g
transform="translate(0.000000,157.000000) scale(0.100000,-0.100000)" transform="translate(0.000000,157.000000) scale(0.100000,-0.100000)"
fill="#000000"
stroke="none" stroke="none"
> >
<path <path

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -8,7 +8,6 @@
> >
<g <g
transform="translate(0.000000,151.000000) scale(0.100000,-0.100000)" transform="translate(0.000000,151.000000) scale(0.100000,-0.100000)"
fill="#000000"
stroke="none" stroke="none"
> >
<path <path

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -8,7 +8,6 @@
> >
<g <g
transform="translate(0.000000,156.000000) scale(0.100000,-0.100000)" transform="translate(0.000000,156.000000) scale(0.100000,-0.100000)"
fill="#000000"
stroke="none" stroke="none"
> >
<path <path

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -8,7 +8,6 @@
> >
<g <g
transform="translate(0.000000,122.000000) scale(0.100000,-0.100000)" transform="translate(0.000000,122.000000) scale(0.100000,-0.100000)"
fill="#000000"
stroke="none" stroke="none"
> >
<path <path

Before

Width:  |  Height:  |  Size: 746 B

After

Width:  |  Height:  |  Size: 729 B

View File

@@ -8,7 +8,6 @@
> >
<g <g
transform="translate(0.000000,182.000000) scale(0.100000,-0.100000)" transform="translate(0.000000,182.000000) scale(0.100000,-0.100000)"
fill="#000000"
stroke="none" stroke="none"
> >
<path <path

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -8,7 +8,6 @@
> >
<g <g
transform="translate(0.000000,181.000000) scale(0.100000,-0.100000)" transform="translate(0.000000,181.000000) scale(0.100000,-0.100000)"
fill="#000000"
stroke="none" stroke="none"
> >
<path <path

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,5 +1,4 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 768 768"> <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="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="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> <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

View 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

View File

@@ -0,0 +1,8 @@
export interface Record {
name: string;
ttl: number;
class: string;
type: string;
data: string;
available?: boolean;
}

View File

@@ -66,6 +66,45 @@ interface NodeStatus {
bootInfo: BootInfo; 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 { export interface Node {
info: NodeStatus; info: NodeStatus;
online: number; online: number;
@@ -76,4 +115,6 @@ export interface Node {
type: string; type: string;
ip: string; ip: string;
level: string; level: string;
vms: Array<VM>;
lxcs: Array<LXC>;
} }

View File

@@ -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 }> { export async function fetchNodes(): Promise<{ nodes: Node[]; cluster: Cluster | null }> {
try { try {
const { nodes, cluster } = await getClusterInfo(); const { nodes, cluster } = await getClusterInfo();
const infoP = Promise.all(nodes.map((node: Node) => fetchNodeInfo(node))); const infoBulk = Promise.all(nodes.map((node: Node) => fetchNodeInfo(node)));
const vmsP = Promise.all(nodes.map((node: Node) => fetchNodeVMs(node))); const vmsBulk = Promise.all(nodes.map((node: Node) => fetchNodeVMs(node)));
const lxcsP = Promise.all(nodes.map((node: Node) => fetchNodeLXCs(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 { return {
cluster, cluster,

175
src/lib/styles/card.scss Normal file
View 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
View 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;

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import Header from '$lib/components/Header.svelte'; import Header from '$lib/components/Header.svelte';
import Sidebar from '$lib/components/Sidebar.svelte'; import Sidebar from '$lib/components/Sidebar.svelte';
import DarkmodeToggle from '$lib/components/DarkmodeToggle.svelte';
import GlobalSearch from '$lib/components/GlobalSearch.svelte';
</script> </script>
<div class="page"> <div class="page">
@@ -13,6 +15,9 @@
<slot></slot> <slot></slot>
</main> </main>
</div> </div>
<DarkmodeToggle />
<GlobalSearch />
</div> </div>
<style lang="scss"> <style lang="scss">

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import PageElement from '$lib/components/PageElement.svelte'; import PageElement from '$lib/components/PageElement.svelte';
import { onMount } from 'svelte';
let elems = []; let elems = [];
let counter = 0; let counter = 0;
@@ -28,10 +29,10 @@
return { return {
bgColor: colors[counter - 1][0], bgColor: colors[counter - 1][0],
color: colors[counter - 1][1], color: colors[counter - 1][1],
title, name: title,
header: null, header: null,
description: description ? description : '', description: description ? description : '',
link: title link: `/${title}`
}; };
} }
@@ -59,11 +60,13 @@
elems = elems.concat(createPageElement('cluster ')); elems = elems.concat(createPageElement('cluster '));
elems = elems.concat(createPageElement('health ')); elems = elems.concat(createPageElement('health '));
onMount(() => window.elements = elems)
</script> </script>
<PageHeader>Welcome to schleppe.cloud infra overview</PageHeader> <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 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 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 (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. general monitoring tools, IoT integrations, and project overviews.
</p> </p>
<p> <p class="site-desc">
The system is intended for hybrid spaces where digital infrastructure coexists with hands-on work. 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 Alongside real-time monitoring, Schleppe is expanding to reflect the broader physical
workspace—covering areas like tool usage, material stocks, and workstations for welding, workspace—covering areas like tool usage, material stocks, and workstations for welding,
@@ -80,11 +83,11 @@
</p> </p>
<div class="shortcut-grid"> <div class="shortcut-grid">
{#each elems as shortcut (shortcut.title)} {#each elems as shortcut (shortcut.name)}
<PageElement <PageElement
bgColor={shortcut.bgColor} bgColor={shortcut.bgColor}
color={shortcut.color} color={shortcut.color}
title={shortcut.title} title={shortcut.name}
header={shortcut.header} header={shortcut.header}
description={shortcut.description} description={shortcut.description}
link={shortcut.link} link={shortcut.link}
@@ -93,18 +96,23 @@
</div> </div>
<style lang="scss"> <style lang="scss">
p { p.site-desc {
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1.4; line-height: 1.4;
line-height: 1.7; line-height: 1.7;
color: #333; color: #333;
background-color: #fafafa; /* Subtle background to separate it from the rest */ background-color: #fafafa;
padding: 2rem; padding: 2rem;
border-radius: 1rem; /* Soft edges */ border-radius: 1rem; /* Soft edges */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); /* Light shadow for depth */ 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 { .shortcut-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import Node from '$lib/components/Node.svelte'; import Node from '$lib/components/Node.svelte';
import Deploy from '$lib/components/Deploy.svelte'; import Deploy from '$lib/components/Deploy.svelte';
import Daemon from '$lib/components/Daemon.svelte'; import Daemon from '$lib/components/Daemon.svelte';
@@ -14,10 +15,22 @@
const rawDaemons: V1DaemonSet[] = data?.daemons; const rawDaemons: V1DaemonSet[] = data?.daemons;
const rawNodes: V1Node[] = data?.nodes; 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 deployments = $derived(rawDeployments.filter((d) => d.metadata.name.includes(filterLC)));
let daemons = $derived(rawDaemons.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))); 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> </script>
<PageHeader>Cluster overview</PageHeader> <PageHeader>Cluster overview</PageHeader>

View 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
View 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>

View File

@@ -22,7 +22,7 @@
columns={['Domain', 'SSL', 'Status', 'Code']} columns={['Domain', 'SSL', 'Status', 'Code']}
> >
<tbody slot="tbody"> <tbody slot="tbody">
{#each httpHealth as row, i (row)} {#each httpHealth as row (row)}
<tr> <tr>
<td>{row.domain}</td> <td>{row.domain}</td>
<td> <td>
@@ -48,7 +48,7 @@
</Table> </Table>
{#if selectedSSL !== null} {#if selectedSSL !== null}
<Dialog on:close={() => (selectedSSL = null)} title="SSL Certificate info"> <Dialog close={() => (selectedSSL = null)} title="SSL Certificate info">
<JsonViewer json={selectedSSL} /> <JsonViewer json={selectedSSL} />
</Dialog> </Dialog>
{/if} {/if}

View File

@@ -247,7 +247,7 @@
</div> </div>
<tbody slot="tbody"> <tbody slot="tbody">
{#each filament as row, i (row)} {#each filament as row (row)}
<tr class="link" on:click={() => goto(filamentLink(row))}> <tr class="link" on:click={() => goto(filamentLink(row))}>
<td><span class="color" style={`background: ${row.hex}`} /></td> <td><span class="color" style={`background: ${row.hex}`} /></td>
<td class="info"> <td class="info">
@@ -268,7 +268,7 @@
{#if open} {#if open}
<Dialog <Dialog
on:close={() => (open = false)} close={() => (open = false)}
title="Add new filament" 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." 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 { &.info {
display: table-cell; display: table-cell;
vertical-align: middle; vertical-align: middle;
padding-left: 25px;
h2 { h2 {
width: 100%; width: 100%;

View File

@@ -1,12 +1,43 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import ServerComp from '$lib/components/Server.svelte'; import ServerComp from '$lib/components/Server.svelte';
import ServerSummary from '$lib/components/ServerSummary.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'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const { cluster, nodes } = data; 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> </script>
<PageHeader>Servers</PageHeader> <PageHeader>Servers</PageHeader>
@@ -15,18 +46,63 @@
<div class="server-list"> <div class="server-list">
{#each nodes as node (node.name)} {#each nodes as node (node.name)}
<div> <ServerComp {node} />
<ServerComp {node} />
</div>
{/each} {/each}
</div> </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"> <style lang="scss">
.server-list { *:not(:last-child) {
margin-bottom: 2rem;
}
.server-list,
.vm-list {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
align-items: left; align-items: left;
gap: 2rem; 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> </style>

View 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
};
};

View 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>

View File

@@ -5,15 +5,17 @@
import FormSite from '$lib/components/forms/FormSite.svelte'; import FormSite from '$lib/components/forms/FormSite.svelte';
import ThumbnailButton from '$lib/components/ThumbnailButton.svelte'; import ThumbnailButton from '$lib/components/ThumbnailButton.svelte';
import type { Site } from '$lib/interfaces/site.ts'; import type { Site } from '$lib/interfaces/site.ts';
import { onMount } from 'svelte';
let { data }: { data: { site: Site } } = $props(); let { data }: { data: { site: Site } } = $props();
let open = $state(false); let open = $state(false);
const { sites } = data; const { sites } = data;
onMount(() => window.elements = sites)
</script> </script>
<PageHeader>Sites <PageHeader>Sites
<button class="add-site-btn affirmative" on:click={() => (open = true)}><span>Add new site</span></button> <button class="add-site-btn affirmative" on:click={() => (open = true)}><span>Add new site</span></button>
</PageHeader> </PageHeader>
@@ -31,7 +33,7 @@
{#if open} {#if open}
<Dialog <Dialog
on:close={() => (open = false)} close={() => (open = false)}
title="Add new site" 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." description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
> >

View File

@@ -28,7 +28,9 @@
font-style: normal; font-style: normal;
} }
:root { :root,
.light {
color-scheme: light;
--bg: #f9f5f3; --bg: #f9f5f3;
--color: #1c1819; --color: #1c1819;
--highlight: #eaddd5; --highlight: #eaddd5;
@@ -37,9 +39,63 @@
--positive: #00d439; --positive: #00d439;
--negative: #ff5449; --negative: #ff5449;
--warning: #ffa312; --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: 1px solid #eaddd5;
--border-radius: 0.75rem; --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 { body {
@@ -51,6 +107,9 @@ body {
background-color: var(--bg); background-color: var(--bg);
color: var(--color); color: var(--color);
font-size: 14px; font-size: 14px;
transition:
background 0.3s ease,
color 0.3s ease;
} }
a, a,
@@ -96,6 +155,7 @@ button {
border: none; border: none;
position: relative; position: relative;
background: transparent; background: transparent;
color: var(--color);
height: 100%; height: 100%;
border-radius: 0.5rem; border-radius: 0.5rem;
display: inline-block; display: inline-block;
@@ -134,6 +194,21 @@ button.affirmative:hover span {
background-color: var(--highlight); 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 { button:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
@@ -191,7 +266,7 @@ table tr:not(table tr:last-of-type) {
table tr:hover > td { table tr:hover > td {
background-color: var(--highlight); background-color: var(--highlight);
background-color: #f5ede9; background-color: var(--bg);
} }
table tr.link { table tr.link {
@@ -204,6 +279,14 @@ table tr.link {
border: var(--border); border: var(--border);
border-radius: var(--border-radius, 1rem); border-radius: var(--border-radius, 1rem);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 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 { .section-wrapper {