working nice. docker uses bun

This commit is contained in:
2025-08-12 23:40:08 +02:00
parent f0922f485d
commit ea9cdb7692
83 changed files with 3005 additions and 422 deletions

View File

@@ -55,6 +55,7 @@ trigger:
- pull_request
branch:
- main
- update
depends_on:
- Build
@@ -117,6 +118,7 @@ trigger:
- pull_request
branch:
- main
- update
depends_on:
- Build
@@ -127,6 +129,6 @@ volumes:
temp: {}
---
kind: signature
hmac: bea117f5e4b51c4fc215ae86962a8cfb24993d9e9b7db3498ab5940b10c70d69
hmac: 83b1ec6458ceddef038000faac2b391d192530ceb681b790ad79c90456f12cca
...

View File

@@ -21,32 +21,9 @@ spec:
- image: ${IMAGE}
imagePullPolicy: IfNotPresent
name: infra-map
env:
- name: HOMEASSISTANT_TOKEN
valueFrom:
secretKeyRef:
envFrom:
- secretRef:
name: secret-env-values
key: HOMEASSISTANT_TOKEN
- name: HOMEASSISTANT_URL
valueFrom:
secretKeyRef:
name: secret-env-values
key: HOMEASSISTANT_URL
- name: PROXMOX_TOKEN
valueFrom:
secretKeyRef:
name: secret-env-values
key: PROXMOX_TOKEN
- name: PROXMOX_URL
valueFrom:
secretKeyRef:
name: secret-env-values
key: PROXMOX_URL
- name: TRAEFIK_URL
valueFrom:
secretKeyRef:
name: secret-env-values
key: TRAEFIK_URL
resources:
limits:
cpu: 900m

View File

@@ -20,4 +20,4 @@ RUN yarn --production
EXPOSE 3000
ENV NODE_ENV=production
CMD [ "node", "build" ]
CMD [ "node", "build/index.js" ]

View File

@@ -1,19 +1,61 @@
# sv
# infra-map
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Configuration setup
## Creating a project
set the following env variables during runtime or update `.env` file.
If you're seeing this, you've probably already done this step. Congrats!
### Kubernetes
Required parameters:
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
KUBERNETES_SERVICE_HOST=https://IP_ADDRESS:16443
KUBERNETES_CA_CART_PATH=kube-ca.crt
KUBERNETES_SA_TOKEN=LKdgk34l...
```
The `KUBERNETES_SERVICE_HOST` is the api server, [microk8s documentation](https://microk8s.io/docs/services-and-ports#services-binding-to-the-default-host-interface) describes that API server is running at port `16443`. Runtime in a pod this will be set by kubernetes.
Get ca_cert from the any running pod at: `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`.
### Proxmox
Required parameters:
```bash
PROXMOX_URL=https://apollo.schleppe:8086/api2/json/
PROXMOX_TOKEN=PVEAPITOKEN=USER@pve!USER=TOKEN_VALUE
```
`PROXMOX_TOKEN` should contain the sub-variable `PVEAPITOKEN` that describes a proxmox API token.
Create api token:
- Create Users
- user name: infra-map
- realm: pve
- expire: never
- Add API tokens
- user: infra-map@pve
- token ID: infra-map
- Permissions
- add: API Token permissions
- path: /
- api-token: infra-map@pve!infra-map
- role: Administrator
- propagate: true
## Home Assistant
Required parameters:
```bash
HOMEASSISTANT_URL=http://homeassistant.schleppe:8123/api/states
HOMEASSISTANT_TOKEN=
```
Follow hass documentation on generating a api token: https://developers.home-assistant.io/docs/auth_api/#long-lived-access-token.
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:

View File

@@ -34,6 +34,9 @@
"dependencies": {
"@kubernetes/client-node": "^1.1.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@zerodevx/svelte-json-view": "^1.0.11",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"sveltekit-sse": "^0.13.16"
}
}

View File

@@ -0,0 +1,88 @@
<script lang="ts">
export let label: string;
export let value = '#000000';
export let placeholder: string | null = null;
export let required = false;
export let icon: unknown = null;
let focus = false;
</script>
<div class="label-input">
{#if label?.length > 0}
<label for={label}>{label}</label>
{/if}
<div class="input" class:focus style={`--bg: ${value}`}>
<div class="color-preview"></div>
<input
id={label}
{placeholder}
name={label}
style={`background-color: ${value}30`}
bind:value
{required}
on:focus={() => (focus = true)}
on:blur={() => (focus = false)}
/>
</div>
</div>
<style lang="scss">
.label-input {
width: 100%;
label {
display: block;
font-weight: 500;
margin-bottom: 0.25rem;
}
.color-preview {
width: 100%;
height: 100%;
margin: 0;
background-color: var(--bg);
border-radius: inherit;
border-bottom-left-radius: unset;
border-bottom-right-radius: unset;
}
.input {
position: relative;
display: flex;
height: 4rem;
flex-direction: column;
align-items: center;
border: 1px solid #a68b85;
border-color: var(--bg);
border-radius: 0.5rem;
transition: all 0.3s;
outline: none;
&.focus {
box-shadow: 0px 0px 0px 4px #7d66654d;
}
}
input {
touch-action: manipulation;
background: transparent;
color: var(--color);
font-size: 1rem;
font-weight: 400;
--padding: 1rem;
width: calc(100% - var(--padding));
padding-left: var(--padding);
height: 100%;
border: none;
outline: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
}
</style>

View File

@@ -8,11 +8,12 @@
const healthy =
status?.desiredNumberScheduled && status?.desiredNumberScheduled === status?.numberReady;
const daemonUrl = `/cluster/daemonset/${metadata?.uid}`;
</script>
<div class="card-container">
<div class="namespace">
<h2>{pods?.length} of {metadata?.name} in {metadata?.namespace}</h2>
<h2>{pods?.length} of <a href={daemonUrl}>{metadata?.name}</a> in {metadata?.namespace}</h2>
</div>
<p>heatlthy: {healthy}</p>
@@ -28,7 +29,7 @@
.card-container {
background-color: #cab2aa40;
border-radius: 0.5rem;
width: 100%;
width: calc(100% - 1.5rem);
padding: 0.75rem;
.namespace {
@@ -38,8 +39,8 @@
.card-wrapper {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
grid-template-columns: var(--grid-tmpl-cols, repeat(3, 1fr));
gap: var(--grid-gap, 2rem);
}
}

View File

@@ -6,11 +6,16 @@
export let deploy: V1Deployment;
let { metadata, pods } = deploy;
const deploymentUrl = `/cluster/deployment/${metadata?.uid}`;
</script>
<div class="card-container">
<div class="namespace">
<h2>{metadata?.name} in {metadata?.namespace}</h2>
<h2>
<a href={deploymentUrl}>{metadata?.name}</a> in
{metadata?.namespace}
</h2>
</div>
<div class="card-wrapper">
@@ -34,8 +39,8 @@
.card-wrapper {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
grid-template-columns: var(--grid-tmpl-cols, repeat(3, 1fr));
gap: var(--grid-gap, 2rem);
}
}
</style>

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { onMount, onDestroy, createEventDispatcher } 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');
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
function handleClick(event: MouseEvent) {
const element = document.getElementsByClassName('dialog')?.[0]?.children[0];
if (clickOutside(event, element) === false) return;
close();
}
onMount(() => {
window.addEventListener('keydown', handleKeydown);
setTimeout(() => window.addEventListener('click', handleClick), 100);
});
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('click', handleClick);
});
</script>
<div
role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
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>
<main>
<div id="dialog-description">
{#if description}
{@html description}
{/if}
</div>
<!--
<div class="alerts">
<Success>There are no applications created yet.</Success>
<Warning>There are no applications created yet.</Warning>
<Error>There are no applications created yet.</Error>
</div>
-->
<div>
<slot></slot>
</div>
</main>
</div>
</div>
<style lang="scss">
:global(.alerts > *) {
margin-bottom: 0.5rem;
}
.dialog {
display: flex;
align-items: flex-start;
justify-content: center;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2006;
width: 100%;
pointer-events: all;
background-color: #7d666580;
transition:
opacity 0.4s ease,
visibility 0.4s ease;
visibility: visible;
opacity: 1;
align-items: center;
> div {
max-width: 880px;
max-width: unset;
}
}
.title {
--padding: 1rem;
position: relative;
background-color: #ffffff;
background-clip: padding-box;
border-radius: 12px;
display: flex;
flex-direction: column;
border: 0;
opacity: 0;
transform: translate(0, 2rem);
transition:
transform 0.4s ease,
opacity 0.4s ease;
box-shadow:
0 3px 6px -4px rgba(0, 0, 0, 0.12),
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;
max-width: 100%;
visibility: visible;
opacity: 1;
transform: translate(0, 0);
header {
padding: 24px;
padding: 24px 16px;
flex: 0 0 auto;
flex-direction: row-reverse;
display: flex;
align-items: center;
button {
flex: unset;
border: none;
padding: 0;
position: relative;
background: transparent;
height: 1.5rem;
width: 1.5rem;
border-radius: 8px;
display: inline-block;
text-decoration: none;
fill: orange;
fill: #0a0a0a;
}
h5 {
margin: 0 auto 0 0;
font-size: 1.4rem;
font-weight: 400;
}
}
main #dialog-description {
padding-bottom: 1rem;
}
main > * {
padding-top: 1rem;
padding-top: 0rem;
}
}
</style>

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
import Input from '$lib/components/Input.svelte';
import { clickOutside } from '$lib/utils/mouseEvents';
export let options = ['Today', 'Yesterday', 'Last 7 Days', 'Last 30 Days', 'All time'];
export let selected;
export let placeholder = '';
export let label = '';
export let icon = undefined;
export let required = false;
let dropdown: Element;
let open = false;
const dispatch = createEventDispatcher();
function select(option: string) {
selected = option;
open = false;
dispatch('value', option);
}
function handleEnter(event: KeyboardEvent): boolean {
if (!(event.code === 'Enter' || event.code === 'Space')) return false;
event.preventDefault();
return true;
}
function handleClick(event: MouseEvent) {
console.log('dropdown element:', dropdown);
const outside = clickOutside(event, dropdown);
console.log('click outside:', outside);
if (outside === false) {
return;
}
open = false;
}
onMount(() => {
window.addEventListener('click', handleClick);
});
onDestroy(() => {
window.removeEventListener('click', handleClick);
});
</script>
<div class="dropdown" bind:this={dropdown}>
<span role="button" class="trigger" on:click={() => (open = !open)}>
<Input
{icon}
{placeholder}
{label}
value={selected}
{required}
on:blur={() => (open = false)}
/>
</span>
{#if open}
<ul class="menu">
{#each options as option (option)}
<li>
<span
tabindex="0"
class:active={selected === option}
on:click={() => select(option)}
on:keydown={(event) => handleEnter(event) && select(option)}
role="button">{option}</span
>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.dropdown {
position: relative;
}
.arrow {
margin-left: auto;
}
.menu {
position: absolute;
top: 100%;
left: 0;
background: var(--bg);
color: var(--color);
border-radius: 6px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
width: 100%;
z-index: 10;
list-style: none;
margin: 0.2rem 0 0 0;
padding: 0;
overflow: hidden;
}
.menu li span {
display: block;
width: 100%;
padding: 10px;
cursor: pointer;
}
.menu li span:hover,
.menu li span:active {
background: #333;
background: var(--color);
color: var(--bg);
}
</style>

View File

@@ -0,0 +1,23 @@
<div type="error" class="negative">
<div>
<slot></slot>
</div>
</div>
<style lang="scss">
.negative {
display: flex;
border-radius: 0.5rem;
color: #1c1819;
background: linear-gradient(90deg, #ff5449 4px, #fed8d0 4px);
> div {
font-size: 1rem;
font-weight: 400;
padding: 1rem 1.25rem;
display: flex;
align-items: flex-start;
flex: 1;
}
}
</style>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import User from '$lib/icons/user.svelte';
import { derived } from 'svelte/store';
// Create a derived store to extract breadcrumb data
@@ -40,6 +41,7 @@
<div class="right">
<span>User profile</span>
<User />
</div>
</div>
@@ -112,4 +114,10 @@
}
}
}
:global(.right svg) {
height: 1.5rem;
width: 1.5rem;
fill: white;
}
</style>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
export let label: string;
export let value: string;
export let placeholder: string;
export let required = false;
export let icon: unknown;
let focus = false;
</script>
<div class="label-input">
{#if label?.length > 0}
<label for={label}>{label}</label>
{/if}
<div class="input" class:focus>
{#if icon}
<i class="icon">
<svelte:component this={icon} />
</i>
{/if}
<input
id={label}
{placeholder}
name={label}
bind:value
{required}
on:focus={() => (focus = true)}
on:blur={() => (focus = false)}
/>
</div>
</div>
<style lang="scss">
.label-input {
width: 100%;
label {
display: block;
font-weight: 500;
margin-bottom: 0.25rem;
}
.input i {
display: block;
--icon-size: 1.2rem;
height: var(--icon-size);
width: var(--icon-size);
fill: #4c4243;
padding-right: 0.25rem;
}
.input {
position: relative;
display: flex;
--padding: 0.75rem;
width: calc(100% - (var(--padding) * 2));
height: 2.5rem;
background: #ffffff;
align-items: center;
gap: 0.5rem;
border: 1px solid #a68b85;
border-radius: 0.5rem;
transition: all 0.3s;
outline: none;
display: flex;
align-items: center;
padding: 0px var(--padding);
&.focus {
box-shadow: 0px 0px 0px 4px #7d66654d;
}
}
input {
touch-action: manipulation;
background: transparent;
color: var(--color);
font-size: 1rem;
font-weight: 400;
width: 100%;
height: 100%;
border: none;
outline: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { JsonView } from '@zerodevx/svelte-json-view';
export let json: any;
</script>
<div class="parent">
<div class="code">
<JsonView {json} />
</div>
</div>
<style lang="scss">
.parent {
margin-bottom: 2rem;
}
.code {
background-color: var(--color);
max-height: calc(90vh - 10rem);
color: white;
border-radius: var(--border-radius, 1rem);
padding: 1rem;
overflow: scroll;
--jsonBorderLeft: 1px dotted var(--highlight);
--jsonKeyColor: #87afff;
--jsonValStringColor: #afaf87;
--jsonValBooleanColor: #ff8787;
--jsonValNumberColor: #5fd787;
}
</style>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { onMount } from 'svelte';
import { grey400x225 } from '$lib/utils/staticImageSource';
import Dialog from './Dialog.svelte';
const IMAGE_PROXY_URL = 'http://localhost:6081';
const IMAGE_REFRESH_INTERVAL = 300;
let { imageUrl }: { imageUrl: string } = $props();
let lastUpdated = new Date();
let timestamp = $state(0);
let fullscreen = $state(false);
let imageSource: string | ArrayBuffer = grey400x225;
let lastCacheSize: string;
function loadBlob(blob: Blob) {
const reader = new FileReader();
reader.onloadend = () => {
imageSource = reader.result || '';
const img = document.getElementById('live-image') as HTMLImageElement;
if (!img) return;
// set imageSource to image element
img.src = `data:image/jpeg;base64; ${imageSource}`;
lastUpdated = new Date();
};
// load blob into FileReader
reader.readAsDataURL(blob);
}
function refetchImage() {
let url;
try {
url = new URL(`${IMAGE_PROXY_URL}/image/${imageUrl}`);
} catch {
console.log('url not valid, returning');
return;
}
const options = {
method: 'GET',
headers: {
'Content-Type': 'image/jpeg',
'If-None-Match': lastCacheSize
}
};
fetch(url.href, options)
.then((resp) => {
if (resp.status === 304) throw Error('image exists');
lastCacheSize = resp.headers.get('Content-Length') || '';
return resp;
})
.then((resp) => resp.blob())
.then((blob) => loadBlob(blob))
.catch(() => {}); // suppress all exceptions
}
function timeDiff(d: Date) {
const seconds = d.getTime();
const v = seconds - lastUpdated.getTime();
return Math.floor(v / 100) / 10;
}
onMount(() => {
refetchImage();
const imageInterval = setInterval(refetchImage, IMAGE_REFRESH_INTERVAL);
const timerInterval = setInterval(() => (timestamp = timeDiff(new Date())), 80);
return () => Promise.all([clearInterval(imageInterval), clearInterval(timerInterval)]);
});
</script>
<div>
{#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" />
</Dialog>
<img src={String(grey400x225)} />
{/if}
<span>Last update {timestamp}s ago</span>
</div>
<style lang="scss">
img {
width: 400px;
border-radius: 0.5rem;
}
span {
display: block;
}
</style>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
export let bgColor: string;
export let color = 'black';
export let title = '';
export let description = '';
export let header = '';
export let link = '/';
export let icon = '';
</script>
<a href={link} class="shortcut" style={`--bg: ${bgColor}; --color: ${color}`}>
<span class="header">{header}</span>
<i class="icon">{icon}</i>
<h2>{title}</h2>
<span class="description">{description}</span>
<button>Utforsk</button>
</a>
<style lang="scss">
.shortcut {
aspect-ratio: 1.5 / 1;
border-radius: 0.4rem;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
text-decoration: none;
color: var(--color);
background-color: var(--bg);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease-in-out;
cursor: pointer;
padding: 2rem 1.25rem;
@media (min-width: 750px) {
aspect-ratio: 1 / 1;
padding: 2.5rem 1.875rem 3.125rem;
.header {
display: block !important;
}
}
@media (min-width: 1200px) {
padding: 3.125rem 3.75rem 3.75rem;
}
&:hover {
transform: translateY(-4px) translateX(-4px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
border-radius: 0.8rem;
}
.header {
display: none;
}
.icon {
font-size: 1.8rem;
}
h2 {
font-size: 4.5rem;
letter-spacing: 4.4px;
font-family: 'Stop';
margin: 0;
flex: unset;
}
.description {
text-align: center;
line-height: 1.4;
}
button {
display: inline-flex;
flex: unset;
color: var(--color);
font-size: 1rem;
border: 2px solid var(--color);
border-radius: 4px;
align-items: center;
height: 48px;
padding: 0 1.25rem;
transition: all 0.3s ease;
}
&:hover button {
color: var(--bg);
background-color: var(--color);
&::after {
opacity: 1;
position: absolute;
top: 14px;
left: calc(100% - 2rem);
}
// padding: 0 3rem;
padding-right: 3rem;
}
button::after {
position: absolute;
transition: inherit;
width: fit-content;
left: 2rem;
top: 12px;
opacity: 0;
content: '→';
}
}
</style>

View File

@@ -3,6 +3,7 @@
import Network from '$lib/icons/network.svelte';
import Layers from '$lib/icons/layers.svelte';
import Clock from '$lib/icons/clock.svelte';
import Sync from '$lib/icons/sync.svelte';
import { formatDuration } from '$lib/utils/conversion';
import { onMount } from 'svelte';
@@ -67,6 +68,12 @@
</div>
<span>{i + 1} of {replicas}</span>
<div class="title">
<Sync />
<span>Restarts</span>
</div>
<span>{status?.containerStatuses?.[0].restartCount}</span>
<div class="title">
<Connection />
<span>Running on Node</span>

View File

@@ -5,7 +5,10 @@
<article class="main-container">
<div class="header">
<div class="title">
<h2>{title}</h2>
<slot name="top-left" />
</div>
<label>{description}</label>
</div>
@@ -25,5 +28,10 @@
.header {
display: flex;
flex-direction: column;
.title {
display: flex;
justify-content: space-between;
}
}
</style>

View File

@@ -11,10 +11,21 @@
import { formatBytes, formatDuration } from '$lib/utils/conversion';
import type { Node } from '$lib/interfaces/proxmox';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import Speed from '$lib/icons/speed.svelte';
import Fingerprint from '$lib/icons/fingerprint.svelte';
export let node: Node;
const buttons = ['View logs', 'Web terminal', 'graphs'];
const buttons = [
{ name: 'View logs', link: `https://${node.ip}:8006/#v1:0:=node%2F${node.name}:4:25::::::` },
{
name: 'Terminal',
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/` }
];
let { cpuinfo, memory, uptime, loadavg } = node.info;
@@ -23,6 +34,9 @@
const lxcsRunning = node.lxcs.filter((l) => l?.template !== 1 && l.status === 'running');
const lxcsTotal = node.lxcs.filter((l) => l?.template !== 1);
const t = cpuinfo.model.match(/(\w+\(\w+\)) (\w+\(\w+\)) (.*)/);
const cpu = t[3].replaceAll(' ', ' ');
onMount(() => {
setInterval(() => (uptime += 1), 1000);
});
@@ -51,6 +65,18 @@
>{cpuinfo.cpus} Cores on {cpuinfo.sockets} {cpuinfo.sockets > 1 ? 'Sockets' : 'Socket'}</span
>
<div class="title">
<Fingerprint />
<span>Model</span>
</div>
<span>{cpu}</span>
<div class="title">
<Speed />
<span>Turbo speed</span>
</div>
<span>{Math.floor(node.info.cpuinfo.mhz) / 1000} GHz</span>
<div class="title">
<Shield />
<span>DDoS protection</span>
@@ -90,9 +116,11 @@
<div class="footer">
{#each buttons as btn (btn)}
<button on:click={() => console.log(node)}>
<span>{btn}</span>
<a href={btn.link} target="_blank" rel="noopener noreferrer">
<button>
<span>{btn.name}</span>
</button>
</a>
{/each}
</div>
</div>
@@ -186,7 +214,7 @@
background-color: var(--bg);
row-gap: 6px;
column-gap: 20px;
max-width: 330px;
> div,
span {
@@ -203,6 +231,7 @@
.footer {
display: flex;
align-items: center;
justify-content: space-evenly;
flex-wrap: wrap;
gap: 0.5rem;

View File

@@ -59,8 +59,12 @@
min-width: var(--nav-width);
margin-right: 1rem;
@media screen and (max-width: 700px) {
--nav-width: 100px;
@media screen and (max-width: 1460px) {
--nav-width: 220px;
margin-left: 0.5rem;
}
@media screen and (max-width: 1200px) {
--nav-width: 140px;
margin-left: 0.5rem;
margin-right: 0;
}

View File

@@ -0,0 +1,23 @@
<div type="warning" class="warning">
<div>
<slot></slot>
</div>
</div>
<style lang="scss">
.warning {
display: flex;
border-radius: 0.5rem;
color: #091d14;
background: linear-gradient(90deg, #02a73a 4px, #62fe67 4px);
> div {
font-size: 1rem;
font-weight: 400;
padding: 1rem 1.25rem;
display: flex;
align-items: flex-start;
flex: 1;
}
}
</style>

View File

@@ -1,5 +1,9 @@
<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 = '';
@@ -22,7 +26,7 @@
<div class="description">{description}</div>
</div>
<div class="actions">
<slot></slot>
<slot name="actions"></slot>
</div>
<table>
<thead>
@@ -38,23 +42,8 @@
{/if}
</tr>
</thead>
<tbody>
{#each data as row, i (row)}
<tr on:click={() => hasLinks && goto(links[i])} class={hasLinks ? 'link' : ''}>
{#each columns as column (column)}
{#if column === 'Link'}
<td><a href={row[column]}>Link</a></td>
{:else if column === 'Hex'}
<td><span class="color" style={`background: ${row[column]}`} /></td>
{:else if Array.isArray(row[column])}
<td>{row[column].join(', ')}</td>
{:else}
<td>{row[column]}</td>
{/if}
{/each}
</tr>
{/each}
</tbody>
<slot name="tbody"></slot>
</table>
{#if footer?.length}
@@ -77,59 +66,6 @@
margin-bottom: 12px;
}
table {
width: 100%;
border-collapse: collapse;
font-family: sans-serif;
border-radius: 8px;
overflow: hidden;
}
th,
td {
padding: 12px;
text-align: left;
transition: background-color 0.25s;
}
th {
font-weight: 500;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-stretch: 2px;
border-bottom: 1px solid #eaddd5;
}
tr {
&:not(&:last-of-type) {
border-bottom: 1px solid #eaddd5;
}
&:hover > td {
background-color: var(--highlight);
background-color: #f5ede9;
}
&.link {
cursor: pointer;
}
}
td {
padding-top: 2rem;
padding-bottom: 2rem;
}
.color {
--size: 2rem;
display: block;
width: calc(var(--size) * 2);
height: var(--size);
margin-top: -calc(var(--size / 2));
margin-bottom: -calc(var(--size / 2));
border-radius: var(--border-radius, 1rem);
}
footer {
margin-top: 1rem;
}

View File

View File

@@ -0,0 +1,126 @@
<script lang="ts">
import External from '$lib/icons/external.svelte';
interface Site {
title: string;
image: string;
link: string;
background?: string;
color?: string;
}
let { title, image, background, color, link }: Site = $props();
let colors = [
['#401C26', '#f6cfdd'],
['#213726', '#BDCBB2'],
['#EED7CD', '#262221'],
['#262221', '#F3BFA2'],
['#f6cfdd', '#401C26'],
['#BDCBB2', '#213726'],
['#FF8FAB', '#401C26'],
['#9381FF', '#262221']
];
if (!background && !color) {
const randomColor = colors[Math.floor(Math.random() * colors.length)];
background = randomColor[0];
color = randomColor[1];
}
</script>
<a
href={link}
style={`--background: ${background}; --color: ${color}`}
target="_blank"
rel="noopener noreferrer"
>
<div class="image" style={`background-image: url("${image}")`}></div>
<hr />
<div class="title">
<h2>
{title || 'Grafana'}
<span class="link"><External /></span>
</h2>
</div>
</a>
<style lang="scss">
a {
border-radius: 0.8rem;
color: white;
text-align: justify;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
cursor: pointer;
background-color: var(--background);
color: var(--color);
&,
h2,
.link,
.title {
transition: all 0.2s ease-in-out;
}
.title {
h2 {
position: relative;
width: fit-content;
margin: 1.2rem auto;
font-size: 2rem;
justify-content: center;
text-transform: lowercase;
font-weight: 300;
letter-spacing: 2px;
font-style: italic;
}
}
hr {
margin: unset;
border: none;
width: 96%;
margin-left: 2%;
height: 1px;
background-color: var(--color);
opacity: 0.3;
}
.image {
height: 8rem;
width: 100%;
margin: 1.2rem 0;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.link {
position: absolute;
transition: inherit;
width: fit-content;
width: 1.5rem;
height: 1.5rem;
left: calc(100% - 2rem);
top: 0;
opacity: 0;
}
&:hover {
transform: translateY(-4px) translateX(-4px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
border-radius: 0.8rem;
.title h2 {
padding-right: 2rem;
.link {
opacity: 1;
left: calc(100% - 1rem);
fill: var(--color);
}
}
}
}
</style>

View File

@@ -0,0 +1,23 @@
<div type="warning" class="warning">
<div>
<slot></slot>
</div>
</div>
<style lang="scss">
.warning {
display: flex;
border-radius: 0.5rem;
color: #1c1819;
background: linear-gradient(90deg, #db7700 4px, #fedd6c 4px);
> div {
font-size: 1rem;
font-weight: 400;
padding: 1rem 1.25rem;
display: flex;
align-items: flex-start;
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Input from '$lib/components/Input.svelte';
import ColorInput from '$lib/components/ColorInput.svelte';
import Dropdown from '$lib/components/Dropdown.svelte';
import Flower from '$lib/icons/flower.svelte';
import Weight from '$lib/icons/weight.svelte';
import Link from '$lib/icons/link.svelte';
import PencilRuler from '$lib/icons/pencil-ruler.svelte';
const dispatch = createEventDispatcher();
const close = () => dispatch('close');
const materialOptions = ['PLA Matte', 'PLA Basic', 'PLA-CF', 'PET-G'];
const weightOptions = ['0.5 kg', '1 kg', '2 kg'];
let process = $state('');
let port = $state('');
</script>
<form method="POST" action="/printer/filament">
<div class="wrapper">
<ColorInput label="Hex" required />
<Input label="Color name" icon={Flower} placeholder="Infinity orange" required />
<Dropdown
placeholder="Plastic material name"
label="Material"
required={true}
icon={PencilRuler}
options={materialOptions}
/>
<Dropdown
placeholder="Spool weight"
label="Weight"
icon={Weight}
required={true}
options={weightOptions}
/>
<Input label="Link" icon={Link} placeholder="https://store.shop/item" 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 connection</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

@@ -0,0 +1,98 @@
<script lang="ts">
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import Tab from '$lib/components/navigation/Tab.svelte';
import Tabs from '$lib/components/navigation/Tabs.svelte';
import TabList from '$lib/components/navigation/TabList.svelte';
import TabView from '$lib/components/navigation/TabView.svelte';
import Section from '$lib/components/Section.svelte';
import JsonViewer from '$lib/components/JsonViewer.svelte';
import type { V1DaemonSet } from '@kubernetes/client-node';
const { daemonset }: { daemonset: V1DaemonSet } = $props();
const { status, spec } = daemonset || {};
let uptime = writable(new Date().getTime() - new Date(status?.startTime || 0).getTime());
onMount(() => {
setInterval(() => uptime.update((n) => n + 1000), 1000);
});
</script>
<Tabs>
<TabList>
<Tab>Details</Tab>
<Tab>Metadata</Tab>
<Tab>Spec</Tab>
<Tab>Status</Tab>
</TabList>
<TabView>
<div class="section-wrapper">
<Section title="Status" description="">
<div class="section-row">
<div class="section-element">
<label>Pods scheduled</label>
<span>{status?.currentNumberScheduled}</span>
</div>
<div class="section-element">
<label>Pods available</label>
<span>{status?.numberAvailable}</span>
</div>
<div class="section-element">
<label>Pods ready</label>
<span>{status?.numberReady}</span>
</div>
<div class="section-element">
<label>Pods misscheduled</label>
<span>{status?.numberMisscheduled}</span>
</div>
</div>
</Section>
<Section title="Spec" description="">
<div class="section-row">
<div class="section-element">
<label>Number of containers</label>
<span>{spec?.template?.spec?.containers.length}</span>
</div>
<div class="section-element">
<label>Number of volumes</label>
<span>{spec?.template?.spec?.volumes?.length}</span>
</div>
<div class="section-element">
<label>Restart policy</label>
<span>{spec?.template?.spec?.restartPolicy}</span>
</div>
<div class="section-element">
<label>Host network</label>
<span>{spec?.template?.spec?.hostNetwork ? 'yes' : 'no'}</span>
</div>
<div class="section-element">
<label>DNS policy</label>
<span>{spec?.dnsPolicy}</span>
</div>
</div>
</Section>
</div>
</TabView>
<TabView>
<JsonViewer json={daemonset.metadata} />
</TabView>
<TabView>
<JsonViewer json={daemonset.spec} />
</TabView>
<TabView>
<JsonViewer json={daemonset.status} />
</TabView>
</Tabs>

View File

@@ -0,0 +1,109 @@
<script lang="ts">
import { onMount } from 'svelte';
import { writable } from 'svelte/store';
import Tab from '$lib/components/navigation/Tab.svelte';
import Tabs from '$lib/components/navigation/Tabs.svelte';
import TabList from '$lib/components/navigation/TabList.svelte';
import TabView from '$lib/components/navigation/TabView.svelte';
import Section from '$lib/components/Section.svelte';
import JsonViewer from '$lib/components/JsonViewer.svelte';
import type { V1Deployment } from '@kubernetes/client-node';
const { deployment }: { deployment: V1Deployment } = $props();
const { status, metadata, spec } = deployment || {};
let uptime = writable(new Date().getTime() - new Date(status?.startTime || 0).getTime());
onMount(() => {
setInterval(() => uptime.update((n) => n + 1000), 1000);
});
</script>
<Tabs>
<TabList>
<Tab>Details</Tab>
<Tab>Metadata</Tab>
<Tab>Spec</Tab>
<Tab>Status</Tab>
</TabList>
<TabView>
<div class="section-wrapper">
<Section title="Status" description="">
<div class="section-row">
<div class="section-element">
<label>Pods ready</label>
<span>{status?.readyReplicas}</span>
</div>
<div class="section-element">
<label>Pods available</label>
<span>{status?.availableReplicas}</span>
</div>
<div class="section-element">
<label>Replicas</label>
<span>{status?.replicas}</span>
</div>
</div>
</Section>
<Section title="Metadata" description="">
<div class="section-row">
<div class="section-element">
<label>Namespace</label>
<span>{metadata?.namespace}</span>
</div>
{#if metadata?.ownerReferences?.length || 0 > 0}
<div class="section-element">
<label>Parent pod</label>
<a
href={`/cluster/${metadata?.ownerReferences?.[0].kind.toLowerCase()}/${
metadata?.ownerReferences?.[0].uid
}`}
sveltekit:reload><span>{metadata?.ownerReferences?.[0].kind}</span></a
>
</div>
{/if}
</div>
</Section>
<Section title="Spec" description="">
<div class="section-row">
<div class="section-element">
<label>Number of containers</label>
<span>{spec?.template?.spec?.containers.length}</span>
</div>
<div class="section-element">
<label>Scheduler</label>
<span>{spec?.schedulerName}</span>
</div>
<div class="section-element">
<label>Host network</label>
<span>{spec?.hostNetwork ? 'yes' : 'no'}</span>
</div>
<div class="section-element">
<label>DNS policy</label>
<span>{spec?.dnsPolicy}</span>
</div>
</div>
</Section>
</div>
</TabView>
<TabView>
<JsonViewer json={deployment.metadata} />
</TabView>
<TabView>
<JsonViewer json={deployment.spec} />
</TabView>
<TabView>
<JsonViewer json={deployment.status} />
</TabView>
</Tabs>

View File

@@ -1,39 +1,33 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { V1Pod } from '@kubernetes/client-node';
import { formatDuration } from '$lib/utils/conversion';
import Tab from '$lib/components/navigation/Tab.svelte';
import Tabs from '$lib/components/navigation/Tabs.svelte';
import TabList from '$lib/components/navigation/TabList.svelte';
import TabView from '$lib/components/navigation/TabView.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Section from '$lib/components/Section.svelte';
import { formatDuration } from '$lib/utils/conversion';
import { onMount, onDestroy } from 'svelte';
import { writable } from 'svelte/store';
import Logs from '$lib/components/Logs.svelte';
import JsonViewer from '$lib/components/JsonViewer.svelte';
import { source } from 'sveltekit-sse';
import type { V1Pod } from '@kubernetes/client-node';
const { pod }: { pod: V1Pod } = $props();
const { status, metadata, spec } = pod || {};
let value = 'no data :(';
if (browser) {
console.log('setting up sse', window.location.pathname);
value = source(window.location.pathname + '/logs').select('message');
console.log(value);
}
let { data }: { data: PageData } = $props();
const { pod }: { pod: V1Pod | undefined } = data;
const { status, metadata, spec } = pod || {};
// console.log(pod);
let uptime = writable(new Date().getTime() - new Date(status?.startTime || 0).getTime());
let logs = writable([]);
let eventSource: EventSource;
const parentUrl = `/cluster/${metadata?.ownerReferences?.[0].kind.toLowerCase()}/${metadata?.ownerReferences?.[0].uid}`;
function setupWS() {
const url = new URL(`${window.location.origin}/cluster/pod/${pod?.metadata?.uid}/logs`);
if (pod?.metadata) {
if (pod?.metadata === undefined) {
console.error('missing pod info. not enough metadata to setup WS connection.');
return;
}
@@ -54,7 +48,13 @@
onMount(() => {
setInterval(() => uptime.update((n) => n + 1000), 1000);
if (browser) {
console.log('setting up sse', window.location.pathname);
value = source(window.location.pathname + '/logs').select('message');
console.log(value);
return setupWS();
}
});
onDestroy(() => {
@@ -65,13 +65,13 @@
});
</script>
<PageHeader>Pod: {pod?.metadata?.name}</PageHeader>
<Tabs>
<TabList>
<Tab>Details</Tab>
<Tab>Logs</Tab>
<Tab>Metadata</Tab>
<Tab>Spec</Tab>
<Tab>Status</Tab>
<Tab>Deployment logs</Tab>
</TabList>
@@ -108,9 +108,11 @@
<span>{metadata?.namespace}</span>
</div>
<div class="section-element">
<label>Parent resource</label>
<span>{metadata?.ownerReferences?.[0].kind}</span>
<div class="section-element" data-sveltekit-preload-data="false">
<label>Parent pod</label>
<a href={parentUrl} sveltekit:reload
><span>{metadata?.ownerReferences?.[0].kind}</span></a
>
</div>
</div>
</Section>
@@ -156,10 +158,18 @@
</TabView>
<TabView>
<Logs logs={JSON.stringify(metadata, null, 2).split('\n')} lineNumbers={false} />
<JsonViewer json={pod.metadata} lineNumbers={false} />
</TabView>
<TabView>
<Logs lineNumbers={false} />
<JsonViewer json={pod.spec} lineNumbers={false} />
</TabView>
<TabView>
<JsonViewer json={pod.status} lineNumbers={false} />
</TabView>
<TabView>
<Logs logs="" lineNumbers={false} />
</TabView>
</Tabs>

View File

@@ -0,0 +1,14 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<path
d="M752 512h-304v-64h48c8.8 0 16-7.2 16-16v-96c0-8.8-7.2-16-16-16h-57.2c-10.5-35.3-29.6-67.6-56.4-94.4-42.3-42.3-98.6-65.6-158.4-65.6s-116.1 23.3-158.4 65.6c-42.3 42.3-65.6 98.6-65.6 158.4v160c0 35.3 28.7 64 64 64h320c35.3 0 64-28.7 64-64h288v64h32v-80c0-8.8-7.2-16-16-16zM480 352v64h-32v-32c0-10.8-0.8-21.5-2.3-32h34.3zM384 544h-320v-160c0-88.2 71.8-160 160-160s160 71.8 160 160v160z"
></path>
<path
d="M224 288c-52.9 0-96 43.1-96 96s43.1 96 96 96 96-43.1 96-96-43.1-96-96-96zM224 448c-35.3 0-64-28.7-64-64s28.7-64 64-64 64 28.7 64 64c0 35.3-28.7 64-64 64z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 699 B

View File

@@ -0,0 +1,30 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<path d="M0 160h32v352h-32v-352z"></path>
<path d="M64 160h64v352h-64v-352z"></path>
<path d="M160 160h32v352h-32v-352z"></path>
<path d="M224 160h32v352h-32v-352z"></path>
<path d="M288 160h64v352h-64v-352z"></path>
<path d="M736 160h32v352h-32v-352z"></path>
<path d="M576 160h64v352h-64v-352z"></path>
<path d="M672 160h32v352h-32v-352z"></path>
<path d="M512 160h32v352h-32v-352z"></path>
<path d="M448 160h32v352h-32v-352z"></path>
<path d="M384 160h32v352h-32v-352z"></path>
<path d="M0 544h32v64h-32v-64z"></path>
<path d="M64 544h64v64h-64v-64z"></path>
<path d="M160 544h32v64h-32v-64z"></path>
<path d="M224 544h32v64h-32v-64z"></path>
<path d="M288 544h64v64h-64v-64z"></path>
<path d="M736 544h32v64h-32v-64z"></path>
<path d="M576 544h64v64h-64v-64z"></path>
<path d="M672 544h32v64h-32v-64z"></path>
<path d="M512 544h32v64h-32v-64z"></path>
<path d="M448 544h32v64h-32v-64z"></path>
<path d="M384 544h32v64h-32v-64z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,23 @@
<!-- Generated by IcoMoon.io -->
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<g id="icomoon-ignore"> </g>
<path d="M192 128h160v32h-160v-32z"></path>
<path d="M192 192h384v32h-384v-32z"></path>
<path d="M192 256h352v32h-352v-32z"></path>
<path d="M192 320h384v32h-384v-32z"></path>
<path d="M192 384h128v32h-128v-32z"></path>
<path d="M192 448h64v32h-64v-32z"></path>
<path d="M192 512h96v32h-96v-32z"></path>
<path
d="M521 542.3c14.3-16.8 23-38.5 23-62.3 0-52.9-43.1-96-96-96s-96 43.1-96 96c0 23.7 8.7 45.5 23 62.3l-85.3 170.6c-3.1 6.2-1.9 13.6 3 18.5s12.3 6.1 18.5 3l49.7-24.8 24.8 49.7c2.7 5.5 8.3 8.8 14.3 8.8 0.6 0 1.3 0 1.9-0.1 6.7-0.8 12.2-5.7 13.7-12.3l32.4-140.5 32.4 140.4c1.5 6.6 7 11.5 13.7 12.3 0.6 0.1 1.3 0.1 1.9 0.1 6 0 11.6-3.4 14.3-8.8l24.8-49.7 49.7 24.8c6.2 3.1 13.6 1.9 18.5-3s6.1-12.3 3-18.5l-85.3-170.5zM448 448c17.6 0 32 14.4 32 32s-14.4 32-32 32-32-14.4-32-32 14.4-32 32-32zM394.4 705.1l-12.1-24.2c-4-7.9-13.6-11.1-21.5-7.2l-21.1 10.5 60.5-121c7.6 4.4 15.9 7.7 24.6 9.9l-30.4 132zM535.2 673.7c-7.9-4-17.5-0.7-21.5 7.2l-12.1 24.2-30.4-131.9c8.7-2.2 17-5.5 24.6-9.9l60.5 121-21.1-10.6z"
></path>
<path
d="M608 0h-448c-35.3 0-64 28.7-64 64v544c0 35.3 28.7 64 64 64h128v-64h-128v-544h448v544c0 0 0 0 0 0v64c35.3 0 64-28.7 64-64v-544c0-35.3-28.7-64-64-64z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,14 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<path
d="M736 0h-256v64h178.7l-401.3 401.4 45.3 45.3 401.3-401.4v178.7h64v-256c0-17.7-14.3-32-32-32z"
></path>
<path
d="M544 736h-512v-512h256v-32h-272c-8.8 0-16 7.2-16 16v544c0 8.8 7.2 16 16 16h544c8.8 0 16-7.2 16-16v-272h-32v256z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@@ -0,0 +1,15 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<path
d="M672 368c0-6.4-0.3-12.7-0.9-19-4.6-92.3-27.6-171.9-67.2-231.6-50.9-76.8-127-117.4-219.9-117.4s-169 40.6-219.9 117.4c-44.6 67.2-68.1 159.3-68.1 266.6 0 88.7 5.7 189.6 48.1 264.6 21.9 38.7 52.4 68.4 90.7 88.1 40.7 21 89.5 31.2 149.2 31.2s108.5-10.2 149.2-31.2c38.3-19.8 68.8-49.4 90.7-88.1 42.4-75 48.1-175.9 48.1-264.6 0-5.4-0.1-10.7-0.2-16h0.2zM217.4 152.8c39.1-58.9 95.1-88.8 166.6-88.8 44.3 0 82.7 11.5 114.7 34.2-11.5-1.4-23.1-2.2-34.7-2.2-50 0-121.8 9.2-179.7 53.3-61.2 46.5-92.3 120.1-92.3 218.7 0 79.4 64.6 144 144 144s144-64.6 144-144v-16h-32v16c0 61.8-50.2 112-112 112s-112-50.2-112-112c0-208.8 150.4-240 240-240 26.6 0 53.2 4.5 78.3 13.1 2.8 3.8 5.6 7.6 8.3 11.7 7.6 11.4 14.4 23.7 20.5 37-31.3-18.9-68-29.8-107.1-29.8-114.7 0-208 93.3-208 208 0 44.1 35.9 80 80 80s80-35.9 80-80c0-26.5 21.5-48 48-48s48 21.5 48 48c0 97-79 176-176 176-95.2 0-173-76-175.9-170.6 1.4-90 21.2-166.1 57.3-220.6zM568.1 617.2c-33.5 59.2-92 86.8-184.1 86.8s-150.7-27.6-184.1-86.8c-1.3-2.3-2.5-4.6-3.7-6.9 37.9 19.4 80.1 29.7 123.8 29.7v-32c-51.8 0-101.4-16.5-142.5-46.8-6.6-24-10.8-49.8-13.4-76.2 37.5 54.9 100.5 91 171.9 91 114.7 0 208-93.3 208-208 0-44.1-35.9-80-80-80s-80 35.9-80 80c0 26.5-21.5 48-48 48s-48-21.5-48-48c0-97 79-176 176-176 50.1 0 95.4 21 127.5 54.8 10.9 40.7 16.5 86.9 16.5 137.2 0 80.4-4.7 171-39.9 233.2z"
></path>
<path d="M320 368v16h32v-16c0-61.8 50.2-112 112-112h16v-32h-16c-79.4 0-144 64.6-144 144z"></path>
<path
d="M512 224v32c15.1 0 30.6 12.4 42.6 34 13.8 24.9 21.4 58.3 21.4 94 0 123.3-70.1 256-224 256v32c80.1 0 147.1-32 193.7-92.5 20.4-26.5 36.2-57.6 47-92.3 10.2-33 15.3-67.6 15.3-103.1 0-89.8-42.2-160.1-96-160.1z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,13 @@
<!-- Generated by IcoMoon.io -->
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<g id="icomoon-ignore"> </g>
<path
d="M768.2 384c0-57.1-37.6-105.6-89.3-122 9.4-18 14.4-38.2 14.4-59.1 0-34.2-13.3-66.3-37.5-90.5s-56.3-37.5-90.5-37.5c-20.9 0-41 5-59.1 14.4-16.4-51.7-64.9-89.3-122-89.3-57.2-0.1-105.7 37.5-122.2 89.3-48.2-25-109.2-17.3-149.6 23.1s-48.1 101.4-23.1 149.6c-51.7 16.4-89.3 64.9-89.3 122s37.6 105.6 89.4 122.1c-25 48.2-17.3 109.2 23.1 149.6s101.3 48.1 149.6 23.1c16.4 51.8 64.9 89.4 122.1 89.4 57.1 0 105.6-37.6 122-89.3 18 9.4 38.2 14.4 59.1 14.4 34.2 0 66.3-13.3 90.5-37.5s37.5-56.3 37.5-90.5c0-20.9-5-41-14.4-59.1 51.7-16.6 89.3-65.1 89.3-122.2zM506.7 167.9c0.6-0.4 1.3-0.9 1.8-1.2 3.1-2.1 7.4-5 11.4-9 25-25 65.6-25 90.5 0 25 25 25 65.6 0 90.5-4 4-6.7 8-8.9 11.3-0.4 0.6-1 1.4-1.4 2-6.3 5.2-10.1 12.5-11.2 20.3l-93.8 38.9c-11.4-19.9-27.9-36.5-47.8-47.8l38.9-93.7c7.9-1.1 15.2-5 20.5-11.3zM384 448c-35.3 0-64-28.7-64-64s28.7-64 64-64c35.3 0 64 28.7 64 64s-28.7 64-64 64zM318.4 142.4c0.7-3.7 1.7-8.8 1.7-14.5 0-35.3 28.7-64 64-64s64 28.7 64 64c0 5.7 1 10.7 1.7 14.4 0.1 0.7 0.3 1.6 0.4 2.3-0.7 8.1 1.7 16 6.4 22.3l-38.9 93.7c-10.8-3-22.1-4.5-33.8-4.5s-23 1.6-33.8 4.5l-38.7-93.6c4.8-6.3 7.3-14.2 6.6-22.4 0.1-0.7 0.3-1.5 0.4-2.2zM157.7 157.6c25-25 65.6-25 90.5 0 4 4 8 6.7 11.3 8.9 0.6 0.4 1.4 1 2 1.4 5.2 6.2 12.5 10.1 20.2 11.2l38.9 93.7c-19.9 11.4-36.5 27.9-47.8 47.8l-93.6-38.8c-1-7.8-4.9-15.2-11.2-20.4-0.4-0.6-0.9-1.2-1.2-1.8-2.1-3.1-5-7.4-9-11.4-25-25-25-65.6-0.1-90.6zM64 384c0-35.3 28.7-64 64-64 5.7 0 10.7-1 14.4-1.7 0.7-0.1 1.6-0.3 2.3-0.4 8.1 0.7 16.1-1.7 22.3-6.5l93.6 38.8c-3 10.8-4.5 22.1-4.5 33.8s1.6 23 4.5 33.8l-93.6 38.8c-6.2-4.8-14.1-7.2-22.3-6.5-0.7-0.1-1.5-0.3-2.2-0.4-3.7-0.7-8.8-1.7-14.5-1.7-35.3 0-64-28.7-64-64zM261.5 600.1c-0.6 0.4-1.2 0.9-1.8 1.2-3.1 2.1-7.4 5-11.4 9-25 25-65.6 25-90.5 0-25-25-25-65.6 0-90.5 4-4 6.7-8 8.9-11.3 0.4-0.6 1-1.4 1.4-2 6.3-5.2 10.1-12.6 11.2-20.4l93.6-38.8c11.4 19.9 27.9 36.5 47.8 47.8l-38.9 93.7c-7.8 1.2-15.1 5.1-20.3 11.3zM449.8 625.6c-0.7 3.7-1.7 8.8-1.7 14.5 0 35.3-28.7 64-64 64s-64-28.7-64-64c0-5.7-1-10.7-1.7-14.4-0.1-0.7-0.3-1.6-0.4-2.3 0.7-8.2-1.7-16.2-6.6-22.4l38.8-93.6c10.8 3 22.1 4.5 33.8 4.5s23-1.6 33.8-4.5l38.9 93.7c-4.7 6.2-7.2 14.1-6.5 22.2-0.1 0.8-0.3 1.6-0.4 2.3zM610.5 610.4c-25 25-65.6 25-90.5 0-4-4-8-6.7-11.3-8.9-0.6-0.4-1.4-1-2-1.4-5.3-6.3-12.6-10.2-20.4-11.2l-38.9-93.7c19.9-11.4 36.5-27.9 47.8-47.8l93.8 38.9c1.1 7.8 4.9 15.1 11.2 20.3 0.4 0.6 0.9 1.3 1.2 1.8 2.1 3.1 5 7.4 9 11.4 25 25 25 65.6 0.1 90.6zM640.2 448c-5.7 0-10.7 1-14.4 1.7-0.7 0.1-1.6 0.3-2.3 0.4-8.2-0.7-16.1 1.7-22.4 6.5l-93.7-38.9c3-10.8 4.5-22.1 4.5-33.8s-1.6-23-4.5-33.8l93.7-38.9c6.2 4.8 14.2 7.3 22.3 6.5 0.7 0.1 1.5 0.3 2.2 0.4 3.7 0.7 8.8 1.7 14.5 1.7 35.3 0 64 28.7 64 64 0.1 35.5-28.6 64.2-63.9 64.2z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

14
src/lib/icons/helm.svelte Normal file
View File

@@ -0,0 +1,14 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<path
d="M384 256c-70.6 0-128 57.4-128 128s57.4 128 128 128c70.6 0 128-57.4 128-128s-57.4-128-128-128zM384 448c-35.3 0-64-28.7-64-64s28.7-64 64-64c35.3 0 64 28.7 64 64s-28.7 64-64 64z"
></path>
<path
d="M768 416v-64h-97.7c-6-54.3-27.1-105.2-61.2-147.8l69.1-69.1-45.3-45.3-69.1 69.2c-42.6-34.2-93.5-55.3-147.8-61.2v-97.8h-64v97.7c-54.3 6-105.2 27.1-147.8 61.2l-69.1-69.1-45.3 45.3 69.1 69.1c-34.2 42.6-55.3 93.5-61.2 147.8h-97.7v64h97.7c6 54.3 27.1 105.2 61.2 147.8l-69.1 69.1 45.3 45.3 69.1-69.1c42.6 34.2 93.5 55.3 147.8 61.2v97.7h64v-97.7c54.3-6 105.2-27.1 147.8-61.2l69.1 69.1 45.3-45.3-69.2-69.1c34.2-42.6 55.3-93.5 61.2-147.8h97.8zM638 352h-64.7c-4.9-29.1-16.4-56.1-32.9-79.2l45.7-45.7c27.6 35.4 46.1 78.2 51.9 124.9zM384 544c-88.2 0-160-71.8-160-160s71.8-160 160-160c88.2 0 160 71.8 160 160s-71.8 160-160 160zM540.9 181.8l-45.7 45.7c-23.1-16.5-50.1-28-79.2-32.9v-64.6c46.7 5.8 89.5 24.3 124.9 51.8zM352 130v64.7c-29.1 4.9-56.1 16.4-79.2 32.9l-45.7-45.7c35.4-27.6 78.2-46.1 124.9-51.9zM181.8 227.1l45.7 45.7c-16.5 23.1-28 50.1-32.9 79.2h-64.6c5.8-46.7 24.3-89.5 51.8-124.9zM130 416h64.7c4.9 29.1 16.4 56.1 32.9 79.2l-45.7 45.7c-27.6-35.4-46.1-78.2-51.9-124.9zM227.1 586.2l45.7-45.7c23.1 16.5 50.1 28 79.2 32.9v64.6c-46.7-5.8-89.5-24.3-124.9-51.8zM416 638v-64.7c29.1-4.9 56.1-16.4 79.2-32.9l45.7 45.7c-35.4 27.6-78.2 46.1-124.9 51.9zM586.2 540.9l-45.7-45.7c16.5-23.1 28-50.1 32.9-79.2h64.6c-5.8 46.7-24.3 89.5-51.8 124.9z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -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: 852 B

After

Width:  |  Height:  |  Size: 854 B

View File

@@ -0,0 +1,17 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<path
d="M748.2 2.4c-12-5-25.7-2.2-34.9 6.9l-704 704c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8h672c35.3 0 64-28.7 64-64v-672c0-12.9-7.8-24.6-19.8-29.6zM640 704v-32h-32v32h-64v-32h-32v32h-64v-32h-32v32h-64v-32h-32v32h-64v-32h-32v32h-114.7l594.7-594.7v114.7h-32v32h32v64h-32v32h32v64h-32v32h32v64h-32v32h32v64h-32v32h32v64h-64z"
></path>
<path
d="M598.1 385.2c-6-2.5-12.9-1.1-17.4 3.5l-192 192c-4.6 4.6-5.9 11.5-3.5 17.4s8.3 9.9 14.8 9.9h192c8.8 0 16-7.2 16-16v-192c0-6.5-3.9-12.3-9.9-14.8zM576 576h-137.4l137.4-137.4v137.4z"
></path>
<path
d="M16 480c1.3 0 2.6-0.2 3.9-0.5l128-32c2.8-0.7 5.4-2.2 7.4-4.2l300-300c32.7-32.7 32.7-85.9 0-118.6s-85.9-32.7-118.6 0l-300 300c-2.1 2.1-3.5 4.6-4.2 7.4l-32 128c-1.4 5.5 0.2 11.2 4.2 15.2 3 3.1 7.1 4.7 11.3 4.7zM432.7 47.3c20.2 20.2 20.2 53.1 0 73.4l-16.7 16.7-73.4-73.4 16.7-16.7c20.2-20.2 53.2-20.2 73.4 0zM62.4 344.2l257.6-257.6 73.4 73.4-257.6 257.6-97.8 24.4 24.4-97.8z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,8 @@
<svg height="100%" width="100%" viewBox="0 0 24 24" aria-hidden="true"
><path
fill="inherit"
fill-rule="evenodd"
d="M10 2a8 8 0 1 0 4.24 14.785l4.687 4.688a1.8 1.8 0 0 0 2.546-2.546l-4.688-4.687A8 8 0 0 0 10 2m-6.2 8a6.2 6.2 0 1 1 12.4 0 6.2 6.2 0 0 1-12.4 0"
clip-rule="evenodd"
></path></svg
>

After

Width:  |  Height:  |  Size: 306 B

View File

@@ -0,0 +1,17 @@
<!-- Generated by IcoMoon.io -->
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="90%"
height="90%"
viewBox="0 0 768 768"
>
<g id="icomoon-ignore"> </g>
<path
d="M345.9 499.3c11.5 8.6 25 12.7 38.3 12.7 19.6 0 38.9-8.9 51.5-25.7 0 0 0-0.1 0.1-0.1 2.7-3.6 4.9-7.3 6.5-11l66.7-141.5c6.5-13.7 2.4-30.2-9.8-39.2-12.1-9.1-29.1-8.4-40.5 1.7l-117.2 104.1c0 0-0.1 0.1-0.1 0.1-3 2.7-5.8 5.8-8.4 9.1 0 0.1-0.1 0.1-0.1 0.2-10.3 13.7-14.6 30.6-12.1 47.5 2.4 17 11.3 32 25.1 42.1zM358.3 429.1c1.4-1.8 2.9-3.4 4.5-4.9l117.1-104.2c0 0 0 0 0 0l-66.7 141.6-0.1 0.3c-0.8 1.7-1.8 3.5-3.2 5.3-10.6 14.1-30.7 17-45 6.4-6.9-5.1-11.3-12.5-12.6-20.9-1.2-8.4 0.9-16.8 6-23.6z"
></path>
<path d="M256 576h256v32h-256v-32z"></path>
<path
d="M737.8 298.5c-19.3-45.7-47-86.8-82.3-122.1s-76.3-62.9-122-82.3c-47.4-19.9-97.7-30.1-149.5-30.1s-102.1 10.2-149.5 30.2c-45.7 19.3-86.8 47-122 82.3s-62.9 76.3-82.3 122.1c-20 47.3-30.2 97.6-30.2 149.4 0 45.4 7.8 89.8 23.3 132.1 14.9 40.8 36.6 78.6 64.4 112.3 6.1 7.4 15.1 11.6 24.7 11.6h543.2c9.6 0 18.6-4.3 24.7-11.6 27.8-33.7 49.4-71.5 64.4-112.3 15.5-42.3 23.3-86.7 23.3-132.1 0-51.8-10.2-102.1-30.2-149.5zM640.1 640h-512.2c-34.9-46.6-56.5-102.2-62.3-160h62.4v-64h-62.4c6.4-64.3 31.9-123 70.7-170.4l41 41 45.3-45.3-41-41c47.4-38.8 106.1-64.3 170.3-70.7v62.4h64v-62.4c64.3 6.4 123.1 31.9 170.5 70.8l-41 41 45.3 45.3 41-41c38.8 47.3 64.3 106 70.7 170.3h-62.4v64h62.4c-5.8 57.8-27.4 113.4-62.3 160z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

15
src/lib/icons/sync.svelte Normal file
View File

@@ -0,0 +1,15 @@
<!-- Generated by IcoMoon.io -->
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 24 24"
>
<path
d="M12 2c2.703 0 5.272 1.1 7.141 3h-4.141v2h6c0.553 0 1-0.447 1-1v-6h-2v3.059c-0.725-0.647-1.525-1.209-2.384-1.666-1.716-0.912-3.659-1.394-5.616-1.394-1.619 0-3.191 0.319-4.672 0.944-1.428 0.603-2.712 1.469-3.813 2.572s-1.966 2.384-2.572 3.813c-0.625 1.481-0.944 3.053-0.944 4.672h2c0-5.512 4.488-10 10-10z"
></path>
<path
d="M22 12c0 5.513-4.488 10-10 10-2.703 0-5.272-1.1-7.141-3h4.141v-2h-6c-0.553 0-1 0.447-1 1v6h2v-3.059c0.725 0.647 1.525 1.209 2.384 1.666 1.719 0.912 3.659 1.394 5.616 1.394 1.619 0 3.191-0.319 4.672-0.944 1.428-0.603 2.712-1.469 3.813-2.572s1.966-2.384 2.572-3.813c0.625-1.481 0.944-3.053 0.944-4.672h-2z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 802 B

14
src/lib/icons/time.svelte Normal file
View File

@@ -0,0 +1,14 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<path
d="M651 271.4c0.8-0.7 1.5-1.4 2.3-2.2 25-25 25-65.6 0-90.5-12.1-12-28.2-18.7-45.3-18.7s-33.2 6.7-45.3 18.7c-0.7 0.7-1.5 1.5-2.2 2.3-43.2-28.7-92.5-46.3-144.6-51.4v-33.6h16c26.5 0 48-21.5 48-48s-21.4-48-47.9-48h-96c-26.5 0-48 21.5-48 48s21.5 48 48 48h16v33.6c-73.4 7.2-141.4 39.3-194.3 92.2-60.4 60.4-93.7 140.7-93.7 226.2s33.3 165.8 93.7 226.3c60.5 60.4 140.8 93.7 226.3 93.7s165.8-33.3 226.3-93.7c60.4-60.5 93.7-140.8 93.7-226.3 0-63.8-18.5-124.7-53-176.6zM320 48c0-8.8 7.2-16 16-16h96c8.8 0 16 7.2 16 16s-7.2 16-16 16h-96c-8.8 0-16-7.2-16-16zM608 192c8.5 0 16.6 3.3 22.6 9.4 12.1 12.1 12.5 31.5 1.1 44.1-6.7-8.2-13.9-16.1-21.5-23.7s-15.5-14.8-23.7-21.5c6-5.4 13.5-8.3 21.5-8.3zM384 704c-141.2 0-256-114.8-256-256s114.8-256 256-256c141.2 0 256 114.8 256 256s-114.8 256-256 256z"
></path>
<path
d="M447 436.3l-31.5-185.8v-0.3c-2.8-15.2-16.1-26.3-31.5-26.3s-28.6 10.9-31.4 26l-31.6 186.4c-0.7 4-1 7.9-1 11.7 0 35.3 28.7 64 64 64s64-28.7 64-64c0-3.7-0.4-7.6-1-11.7v0zM384 480c-17.6 0-32-14.4-32-32 0-2 0.2-4.1 0.6-6.3l31.3-185.7c0 0 0 0 0 0.1l31.4 185.2h-0.1c0.5 2.4 0.7 4.7 0.7 6.7 0.1 17.6-14.3 32-31.9 32z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,9 @@
<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>
<path d="M736 320c0 158.8-129.2 288-288 288v32c85.5 0 165.8-33.3 226.3-93.7 60.4-60.5 93.7-140.8 93.7-226.3h-32z"></path>
<path d="M448 32v-32c-85.5 0-165.8 33.3-226.3 93.7-60.4 60.5-93.7 140.8-93.7 226.3h32c0-158.8 129.2-288 288-288z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

16
src/lib/icons/user.svelte Normal file
View File

@@ -0,0 +1,16 @@
<!-- Generated by IcoMoon.io -->
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<g id="icomoon-ignore"> </g>
<path
d="M384 448c105.9 0 192-86.1 192-192s-86.1-192-192-192-192 86.1-192 192 86.1 192 192 192zM384 128c70.6 0 128 57.4 128 128s-57.4 128-128 128c-70.6 0-128-57.4-128-128s57.4-128 128-128z"
></path>
<path
d="M630.7 546.2c-44.2-44.6-124.9-66.2-246.7-66.2s-202.5 21.6-246.7 66.2c-41.3 41.6-41.3 92.3-41.3 125.8v16c0 8.8 7.2 16 16 16h544c8.8 0 16-7.2 16-16v-16c0-33.5 0-84.2-41.3-125.8zM160 672c0-28.4 0-57.9 22.7-80.7 13.5-13.6 34.3-24.4 62-32.2 35.4-10 82.3-15.1 139.3-15.1s103.9 5.1 139.3 15.1c27.7 7.8 48.5 18.6 62 32.2 22.7 22.8 22.7 52.3 22.7 80.7h-448z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 755 B

View File

@@ -0,0 +1,22 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<path d="M160 576h160v32h-160v-32z"></path>
<path d="M160 448h160v32h-160v-32z"></path>
<path d="M160 384h160v32h-160v-32z"></path>
<path d="M448 576h160v32h-160v-32z"></path>
<path d="M448 448h160v32h-160v-32z"></path>
<path d="M448 384h160v32h-160v-32z"></path>
<path d="M160 512h160v32h-160v-32z"></path>
<path d="M448 512h160v32h-160v-32z"></path>
<path
d="M292 314.5c3 3.5 7.4 5.5 12 5.5h160c4.6 0 9-2 12-5.5l64-73c2.8-3.3 4.3-7.5 3.9-11.8-0.3-4.3-2.4-8.3-5.7-11-48.4-40-97.4-58.7-154.2-58.7s-105.8 18.7-154.2 58.7c-3.3 2.8-5.4 6.7-5.7 11s1.1 8.6 3.9 11.8l64 73zM384 192c44.6 0 82.4 12.8 120.8 41.2l-48 54.8h-46l6.9-62.1-31.8-3.5-7.3 65.7h-67.3l-48-54.8c38.3-28.5 76.1-41.3 120.7-41.3z"
></path>
<path
d="M608 32h-448c-70.6 0-128 57.4-128 128v448c0 70.6 57.4 128 128 128h448c70.6 0 128-57.4 128-128v-448c0-70.6-57.4-128-128-128zM672 608c0 35.3-28.7 64-64 64h-448c-35.3 0-64-28.7-64-64v-448c0-35.3 28.7-64 64-64h448c35.3 0 64 28.7 64 64v448z"
></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,11 @@
export enum HEALTH_STATUS {
LIVE = 'live',
UNKNOWN = 'unknown',
DOWN = 'down'
}
export interface HttpEndpoint {
domain: string;
code: number;
status: HEALTH_STATUS.DOWN;
}

View File

@@ -1,8 +1,10 @@
export interface Filament {
Hex: string;
Color: string;
Material: string;
Weight: string;
Count: number;
Link: string;
hex: string;
color: string;
material: string;
weight: string;
count: number;
link: string;
created: number;
updated: number;
}

135
src/lib/server/database.ts Normal file
View File

@@ -0,0 +1,135 @@
import { currentFilament } from './filament';
import { open } from 'sqlite';
import sqlite3 from 'sqlite3';
import { resolve } from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import type { Filament } from '$lib/interfaces/printer';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = resolve(__dirname, '../../../db.sqlite');
let db;
async function initDb() {
const db = await open({
filename: dbPath,
driver: sqlite3.Database
});
// Transaction to run schemas
await db.exec('BEGIN TRANSACTION');
try {
for (const stmt of schemas) {
await db.run(stmt);
}
await db.exec('COMMIT');
} catch (err) {
console.error('Failed to create tables:', err.message);
await db.exec('ROLLBACK');
}
return db;
}
const schemas = [
`
CREATE TABLE IF NOT EXISTS filament (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hex TEXT NOT NULL,
color TEXT NOT NULL,
material TEXT,
weight REAL,
link TEXT,
added INTEGER, -- epoch seconds
updated INTEGER -- epoch seconds
)
`
];
async function seedData(db) {
const baseTimestamp = Math.floor(new Date('2025-04-01T05:47:01+00:00').getTime() / 1000);
const filaments = currentFilament();
const stmt = await db.prepare(`
INSERT OR IGNORE INTO filament (hex, color, material, weight, link, added, updated)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
await db.exec('BEGIN TRANSACTION');
try {
for (const f of filaments) {
const existing = await db.get('SELECT 1 FROM filament WHERE hex = ? AND updated = ?', [
f.hex,
baseTimestamp
]);
if (!existing) {
await db.run(
`INSERT INTO filament (hex, color, material, weight, link, added, updated)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[f.hex, f.color, f.material, f.weight, f.link, baseTimestamp, baseTimestamp]
);
}
}
await db.exec('COMMIT');
} catch (err) {
console.error('Failed to seed data:', err.message);
await db.exec('ROLLBACK');
} finally {
await stmt.finalize();
}
}
// Export helper to use db elsewhere
async function getDb() {
if (db !== undefined) return db;
db = await initDb();
await seedData(db);
console.log('Database setup and seeding complete!');
}
export async function getAllFilament(): Promise<Array<Filament>> {
const db = await getDb();
const result = await db?.all('SELECT * FROM filament');
return result || [];
}
export async function getFilamentByColor(name: string) {
const db = await getDb();
const result = await db?.get('SELECT * FROM filament WHERE LOWER(color) = ?', [name]);
return result || undefined;
}
export async function addFilament(
hex: string,
color: string,
material: string,
weight: number,
link: string
) {
const timestamp = Math.floor(new Date().getTime() / 1000);
const db = await getDb();
const result = await db.run(
`INSERT INTO filament (hex, color, material, weight, link, added, updated)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[hex, color, material, weight, link, timestamp, timestamp]
);
return { id: result.lastID };
}
export async function updatefilament({ id, make, model, year }) {
const db = await getDb();
await db.run(
'UPDATE filaments SET make = ?, model = ?, year = ? WHERE id = ?',
make,
model,
year,
id
);
}

View File

@@ -0,0 +1,54 @@
import { request, Agent } from 'https';
import tls, { type PeerCertificate } from 'tls';
const SSL_WEBSERVER = '10.0.0.53';
export async function getSSLInfo(url: string, port = 443) {
if (new URL(url).protocol !== 'https:') return { raw: 'none' };
const hostname = new URL(url).hostname;
return new Promise((resolve, reject) => {
const socket = tls.connect(port, SSL_WEBSERVER, { servername: hostname }, () => {
const cert = socket.getPeerCertificate(true);
if (!cert || Object.keys(cert).length === 0) {
reject(new Error('No certificate found'));
return;
}
resolve({
subject: cert.subject,
issuer: cert.issuer,
valid_from: cert.valid_from,
valid_to: cert.valid_to,
fingerprint: cert.fingerprint,
fingerprint256: cert.fingerprint256,
ca: cert.ca,
nistCurve: cert.nistCurve,
asn1Curve: cert.asn1Curve,
serialNumber: cert.serialNumber,
altNames: cert.subjectaltname,
publicKey: cert?.pubkey?.toString('base64') || '',
infoAccess: cert?.infoAccess || ''
});
socket.end();
});
socket.on('error', (err) => {
reject(err);
});
});
}
export async function healthOk(url: string): Promise<number> {
return fetch(url, { signal: AbortSignal.timeout(400) })
.then((resp) => {
return resp.status;
})
.catch((error) => {
console.log('got error from health endpoint for url:', url);
console.log(error);
return 550;
});
}

View File

@@ -16,19 +16,70 @@ function buildHomeassistantRequest() {
return { url, options };
}
const attributes = {
current_stage: null,
print_status: null,
bed_temperature: null,
nozzle_temperature: null,
total_usage: null,
nozzle_type: null,
nozzle_size: null,
print_bed_type: null,
current_layer: null,
total_layer_count: null,
print_progress: null,
print_length: null,
print_weight: null,
sd_card_status: null,
speed_profile: null,
wi_fi_signal: null,
end_time: null,
cover_image: null,
pick_image: null,
camera: null
};
interface PrinterState {
[key: string]: {
value: string;
unit?: string;
picture?: string;
};
}
function printerState(data: object) {
const state: PrinterState = {};
const keys = Object.keys(attributes);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const value = data?.filter((el) => el.entity_id.includes(k))[0];
if (!value) continue;
state[k] = { value: value.state };
if (value?.attributes?.unit_of_measurement)
state[k]['unit'] = value.attributes.unit_of_measurement;
if (value?.attributes?.entity_picture) state[k]['picture'] = value.attributes.entity_picture;
}
return state;
}
async function fetchHassStates() {
const { url, options } = buildHomeassistantRequest();
return fetch(url, options).then((resp) => resp.json());
}
export async function fetchP1P(): Promise<Entity[]> {
export async function fetchP1P(): Promise<PrinterState> {
try {
let hassStates = await fetchHassStates();
hassStates = hassStates.filter(
(el: Entity) => el.attributes.friendly_name?.includes('P1P') === true
);
return hassStates;
return printerState(hassStates);
} catch (error) {
console.log('ERROR! from fetchP1P:', error);
return Promise.reject(null);

View File

@@ -1,9 +1,46 @@
import * as k8s from '@kubernetes/client-node';
import stream from 'stream';
import { writable } from 'svelte/store';
import fs from 'fs';
import { env } from '$env/dynamic/private';
/*
const kubeCaPath =
env.KUBERNETES_CA_CERT_PATH || '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt';
const kubeCaCert = fs.readFileSync(kubeCaPath, 'utf8');
// const kubeSaTokenPath = env.KUBERNETES_SA_TOKEN_PATH || '/var/run/secrets/kubernetes.io/serviceaccount/token';
const token = fs.readFileSync(kubeSaTokenPath, 'utf8');
*/
const kubeConfig: k8s.KubeConfig = {
clusters: [
{
name: 'kazan',
server: env.KUBERNETES_SERVICE_HOST || 'https://kubernetes.default.svc',
// caData: kubeCaCert,
// skipTLSVerify: true
skipTLSVerify: true
}
],
users: [
{
name: 'pod-user',
token: env.KUBERNETES_SA_TOKEN
}
],
contexts: [
{
name: 'default-context',
user: 'pod-user',
cluster: 'kazan'
}
],
currentContext: 'default-context'
};
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
kc.loadFromOptions(kubeConfig);
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
const appsV1Api = kc.makeApiClient(k8s.AppsV1Api);
@@ -92,12 +129,16 @@ export function createLogStream(podName: string, namespace: string, containerNam
});
console.log('setting logAbortController, prev:', logAbortController);
try {
logAbortController = await k8sLog.log(namespace, podName, containerName, liveStream, {
follow: true,
timestamps: false,
pretty: false,
tailLines: maxLines
});
} catch (error) {
console.log('ERROR SETTING UP WS', error);
}
}
function stop() {

View File

@@ -2,8 +2,8 @@ import { env } from '$env/dynamic/private';
import type { Cluster, Node } from '$lib/interfaces/proxmox';
function buildProxmoxRequest() {
const url = env.PROXMOX_URL || 'https://10.0.0.50:8006/api2/json/';
const token = env.PROXMOX_TOKEN || 'REPLACE_WITH_PROXMOX_TOKEN';
const url = env.PROXMOX_URL;
const token = env.PROXMOX_TOKEN;
const options = {
method: 'GET',
headers: {

View File

@@ -3,7 +3,7 @@ import { env } from '$env/dynamic/private';
const TRAEFIK_HTTP_URL = '/api/http';
function buildTraefikRequest(path: string) {
const baseURL = env.TRAEFIK_URL || 'http://localhost:9000';
const baseURL = env.TRAEFIK_URL;
const url = `${baseURL}${TRAEFIK_HTTP_URL}/${path}`;
const options = {
method: 'GET',
@@ -12,6 +12,7 @@ function buildTraefikRequest(path: string) {
}
};
console.log('making traefik request', url);
return { url, options };
}

View File

@@ -26,6 +26,20 @@ export function formatDuration(seconds: number) {
return `${days} days ${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(Math.floor(seconds)).padStart(2, '0')}`;
}
export function daysUntil(dateString: string) {
const inputDate = new Date(dateString);
const today = new Date();
// Clear time components for accurate day comparison
inputDate.setHours(0, 0, 0, 0);
today.setHours(0, 0, 0, 0);
const diffTime = inputDate - today;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
}
export function convertKiToHumanReadable(input: string) {
const match = input.match(/^(\d+)(Ki)$/);
if (!match) return 'Invalid input';
@@ -44,3 +58,38 @@ export function convertKiToHumanReadable(input: string) {
return `${humanReadable.toFixed(2)} ${sizes[i]}`;
}
export function formatTimeLeft(seconds: number, short = false) {
const units = [
{ label: 'mo', value: 2592000 }, // 30 days as an average month
{ label: 'd', value: 86400 },
{ label: 'h', value: 3600 },
{ label: 'm', value: 60 },
{ label: 's', value: 1 }
];
let remaining = seconds;
const parts = [];
for (const unit of units) {
if (remaining >= unit.value) {
const amount = Math.floor(remaining / unit.value);
remaining %= unit.value;
parts.push(`${amount}${unit.label}`);
}
}
if (short) return parts.slice(' ')[0];
// If 0 seconds, still return "0s"
return parts.length > 0 ? parts.join(' ') : '0s';
}
export function formatDateIntl(d: Date) {
return new Intl.DateTimeFormat('nb-NO', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short'
}).format(d);
}

22
src/lib/utils/hash.ts Normal file
View File

@@ -0,0 +1,22 @@
const cyrb64 = (str: string, seed = 0) => {
let h1 = 0xdeadbeef ^ seed,
h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
// For a single 53-bit numeric return value we could return
// 4294967296 * (2097151 & h2) + (h1 >>> 0);
// but we instead return the full 64-bit value:
return [h2 >>> 0, h1 >>> 0];
};
export const digest = (str: string, seed = 0) => {
const [h2, h1] = cyrb64(str, seed);
return h2.toString(36).padStart(7, '0') + h1.toString(36).padStart(7, '0');
};

View File

@@ -0,0 +1,14 @@
export function clickOutside(event: MouseEvent, element: Element | undefined) {
if (!element) return false;
const rect = element.getBoundingClientRect();
if (!rect) return false;
const isClickOutside =
event.clientX < rect.left ||
event.clientX > rect.right ||
event.clientY < rect.top ||
event.clientY > rect.bottom;
return isClickOutside ? true : false;
}

View File

@@ -0,0 +1,2 @@
export const grey400x225 =
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCADhAZADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3aiiipKCiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA//Z';

View File

@@ -30,8 +30,19 @@
main {
/* mobile: 1rem */
margin: 2rem;
width: 100%;
--margin: 2rem;
margin: var(--margin);
width: calc(100% - var(--margin) * 2);
}
@media screen and (max-width: 750px) {
main {
--margin: 1rem;
}
:global(> .nav-wrapper) {
display: none;
}
}
}
</style>

View File

@@ -1,82 +1,124 @@
<script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte';
import PageElement from '$lib/components/PageElement.svelte';
let elems = [];
let counter = 0;
let colors = [
['#401C26', '#f6cfdd'],
['#213726', '#BDCBB2'],
['#EED7CD', '#262221'],
['#262221', '#F3BFA2'],
['#f6cfdd', '#401C26'],
['#BDCBB2', '#213726'],
['#FF8FAB', '#401C26'],
['#9381FF', '#262221']
];
const descriptions = [
'Røroshotellene, eid av Røros Hotell AS, er fire unike hoteller og spisesteder på Røros. Med røtter i lokalsamfunnet tilbyr vi autentiske opplevelser, enten du vil bo komfortabelt eller nyte lokal mat',
'Faglige møter blir enda bedre med frisk høstluft og unike omgivelser. Kampanjepris fra 2115,-',
'Planlegg et minneverdig bedriftsarrangement hos oss!',
'Lei hele Erzscheidergården for en utforstyrret ramme til ditt neste møtested!'
];
function createPageElement(title: string, description = '', header = '') {
if (counter + 1 >= colors.length) counter = 1;
else counter += 1;
console.log(counter);
return {
bgColor: colors[counter - 1][0],
color: colors[counter - 1][1],
title,
header: null,
description: description ? description : '',
link: title
};
}
elems = elems.concat(createPageElement('sites'));
elems = elems.concat(createPageElement('servers', 'Overview of proxmox servers'));
elems = elems.concat(
createPageElement(
'printer',
'Realtime information on P1P printer and filament overview with current & historical rolls.'
)
);
elems = elems.concat(
createPageElement(
'network',
'View traefik configuration & all defined routes, services & middlewares.'
)
);
elems = elems.concat(
createPageElement(
'cluster',
'View running resources in Kubernetes cluster. View nodes, daemonset & deployments; and get a pods realtime logs, resource usage & view related kubernetes resources.'
)
);
elems = elems.concat(createPageElement('health'));
elems = elems.concat(createPageElement('cluster '));
elems = elems.concat(createPageElement('health '));
</script>
<PageHeader>Welcome to SvelteKit</PageHeader>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<PageHeader>Welcome to schleppe.cloud infra overview</PageHeader>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
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
page with relevant real-time and historical information. More modules are planned, including
general monitoring tools, IoT integrations, and project overviews.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
</p>
<p>
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Pariatur laboriosam, necessitatibus
repudiandae quo voluptatem ratione, sit eum dolores enim earum at officia expedita consequuntur
ipsum voluptatum sunt, atque magni minus.
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,
woodworking, electronics, and leathercraft. The goal is to make the state of the entire
workshop—both virtual and physical—easily visible in one place.
</p>
<div class="shortcut-grid">
{#each elems as shortcut (shortcut.title)}
<PageElement
bgColor={shortcut.bgColor}
color={shortcut.color}
title={shortcut.title}
header={shortcut.header}
description={shortcut.description}
link={shortcut.link}
/>
{/each}
</div>
<style lang="scss">
p {
font-size: 1.1rem;
line-height: 1.4;
line-height: 1.7;
max-width: 80%;
color: #333;
background-color: #fafafa; /* Subtle background to separate it from the rest */
padding: 2rem;
border-radius: 1rem; /* Soft edges */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); /* Light shadow for depth */
}
.shortcut-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
gap: 1.2rem;
margin-top: 2rem;
}
:global(.shortcut-grid .shortcut:nth-of-type(odd) h2, .shortcut-grid a:nth-of-type(odd) h2) {
font-weight: 600;
letter-spacing: 2.6px;
font-family: 'Norman' !important;
}
</style>

View File

@@ -3,18 +3,28 @@
import Deploy from '$lib/components/Deploy.svelte';
import Daemon from '$lib/components/Daemon.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Input from '$lib/components/Input.svelte';
import type { PageData } from './$types';
import type { V1DaemonSet, V1Deployment, V1Node } from '@kubernetes/client-node';
let { data }: { data: PageData } = $props();
let filterValue = $state('');
const deployments: V1Deployment[] = data?.deployments;
const daemons: V1DaemonSet[] = data?.daemons;
const nodes: V1Node[] = data?.nodes;
const rawDeployments: V1Deployment[] = data?.deployments;
const rawDaemons: V1DaemonSet[] = data?.daemons;
const rawNodes: V1Node[] = data?.nodes;
let deployments = $derived(rawDeployments.filter((d) => d.metadata.name.includes(filterValue)));
let daemons = $derived(rawDaemons.filter((d) => d.metadata.name.includes(filterValue)));
let nodes = $derived(rawNodes.filter((n) => n.metadata.name.includes(filterValue)));
</script>
<PageHeader>Cluster overview</PageHeader>
<div class="search-section">
<Input label="Filter resources" placeholder="Search by name" bind:value={filterValue} />
</div>
<details open>
<summary>
<h2>Cluster <span>{nodes.length} nodes</span></h2>
@@ -65,12 +75,27 @@
</details>
<style lang="scss">
.search-section {
padding: 1.714rem 0px;
}
.server-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
--grid-tmpl-cols: repeat(2, 1fr);
--grid-gap: 0.5rem;
grid-template-columns: var(--grid-tmpl-cols, repeat(2, 1fr));
gap: var(--grid-gap, 0.5rem);
margin-bottom: 2rem;
@media screen and (min-width: 750px) {
--grid-tmpl-cols: repeat(2, 1fr);
--grid-gap: 1.25rem;
}
@media screen and (min-width: 1200px) {
--grid-tmpl-cols: repeat(3, 1fr);
--grid-gap: 2rem;
}
}
.server-list.deploys {
@@ -88,7 +113,8 @@
cursor: pointer;
}
h2 {
:global(.server-list h2) {
font-family: 'Reckless Neue';
justify-content: unset !important;
}
</style>

View File

@@ -0,0 +1,59 @@
import type { PageServerLoad } from './$types';
import { getPods, getDaemons, getReplicas, getDeployments } from '$lib/server/kubernetes';
import type { V1DaemonSet, V1Deployment, V1Pod } from '@kubernetes/client-node';
const AVAILABLE_RESOURCES = [
'pod',
'deployment',
'daemonset',
'cronjobs',
'configmap',
'replicaset'
];
export const load: PageServerLoad = async ({ params }) => {
const { resource, uid } = params;
console.log('PARAMS:', params);
if (!AVAILABLE_RESOURCES.includes(resource)) {
return {
error: 'No resource ' + resource,
resource: null
};
}
console.log(uid);
let resources: V1Pod[];
switch (resource) {
case 'pod':
const podsResp: V1Pod[] = await getPods();
resources = JSON.parse(JSON.stringify(podsResp));
break;
case 'daemonset':
const daemonsResp: V1DaemonSet[] = await getDaemons();
resources = JSON.parse(JSON.stringify(daemonsResp));
break;
case 'deployment':
const deploymentResp: V1Deployment[] = await getDeployments();
resources = JSON.parse(JSON.stringify(deploymentResp));
break;
case 'replicaset':
console.log('replicas');
const replicasResp: V1ReplicaSet[] = await getReplicas();
console.log('replicas', replicasResp);
resources = JSON.parse(JSON.stringify(replicasResp));
break;
default:
console.log('no resources found');
}
const singleResource = resources?.find((p) => p.metadata?.uid === uid);
delete singleResource?.metadata?.managedFields;
return {
resource: singleResource,
kind: resource,
error: null
};
};

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte';
import PodDescribe from '$lib/components/kube-describe/Pod.svelte';
import DeploymentDescribe from '$lib/components/kube-describe/Deployment.svelte';
import DaemonSetDescribe from '$lib/components/kube-describe/DaemonSet.svelte';
import type { PageData } from './$types';
import type { V1Pod } from '@kubernetes/client-node';
let { data }: { data: PageData } = $props();
const { error, kind } = data;
const { resource }: { pod: V1Pod | undefined } = data;
</script>
<PageHeader>{kind || 'Resource'}: {resource?.metadata?.name || 'not found'}</PageHeader>
{#if error}
<p>{error}</p>
{/if}
{#if resource}
{#if kind == 'pod'}
<PodDescribe pod={resource} />
{:else if kind == 'deployment' || kind == 'replicaset'}
<DeploymentDescribe deployment={resource} />
{:else if kind == 'daemonset'}
<DaemonSetDescribe daemonset={resource} />
{:else}
<PodDescribe pod={resource} />
{/if}
{:else}
<h2>404. '{kind}' resource not found!</h2>
{/if}

View File

@@ -1,17 +0,0 @@
import type { PageServerLoad } from './$types';
import { getPods } from '$lib/server/kubernetes';
import type { V1Pod } from '@kubernetes/client-node';
export const load: PageServerLoad = async ({ params }) => {
const { uid } = params;
console.log(uid);
const podsResp = await getPods();
const pods: V1Pod[] = JSON.parse(JSON.stringify(podsResp));
const pod = pods.find((p) => p.metadata?.uid === uid);
return {
pod
};
};

View File

@@ -0,0 +1,30 @@
import type { PageServerLoad } from './$types';
import { env } from '$env/dynamic/private';
import { getSSLInfo, healthOk } from '$lib/server/health_http';
import { HEALTH_STATUS, type HttpEndpoint } from '$lib/interfaces/health';
const ENDPOINTS: string[] = env?.HTTP_HEALTH_ENDPOINTS?.split(',');
export const load: PageServerLoad = async (): Promise<{ httpHealth: HttpEndpoint[] }> => {
const statusPromises = ENDPOINTS?.map(async (endpointUrl) => {
const status = await healthOk(endpointUrl);
const ssl = await getSSLInfo(endpointUrl);
return { status, ssl };
});
const endpointStatuses = await Promise.all(statusPromises);
const httpHealth = ENDPOINTS.map((domain, i) => {
return {
domain: new URL(domain).hostname,
code: endpointStatuses[i].status,
ssl: endpointStatuses[i].ssl,
status:
String(endpointStatuses[i].status)[0] !== '5' ? HEALTH_STATUS.LIVE : HEALTH_STATUS.DOWN
} as HttpEndpoint;
});
return {
httpHealth
};
};

View File

@@ -1,13 +1,17 @@
<script>
<script lang="ts">
import Dialog from '$lib/components/Dialog.svelte';
import JsonViewer from '$lib/components/JsonViewer.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Table from '$lib/components/Table.svelte';
import Certificate from '$lib/icons/certificate.svelte';
import type { HttpEndpoint } from '$lib/interfaces/health';
import { daysUntil } from '$lib/utils/conversion';
import type { PageData } from './$types';
let columns = ['Domain', 'Status'];
let data = [
{ Domain: 'laravel-ucm1d.kinsta.app', Status: 'Live' },
{ Domain: 'laravel-ucm1d.kinsta.app', Status: 'Live' },
{ Domain: 'laravel-ucm1d.kinsta.app', Status: 'Live' }
];
let { data }: { data: PageData } = $props();
const httpHealth: HttpEndpoint[] = data?.httpHealth;
let selectedSSL = $state(null);
</script>
<PageHeader>Health</PageHeader>
@@ -15,6 +19,36 @@
<Table
title="Domains list"
description="All of the verified domains below point to this application and are covered by free Cloudflare SSL certificates for a secure HTTPS connection. The DNS records for the domains must be set up correctly for them to work."
{columns}
{data}
/>
columns={['Domain', 'SSL', 'Status', 'Code']}
>
<tbody slot="tbody">
{#each httpHealth as row, i (row)}
<tr>
<td>{row.domain}</td>
<td>
{#if row['ssl']['valid_to']}
<div
on:click={() => (selectedSSL = row['ssl'])}
style="display: flex; cursor: pointer;"
>
<div style="height: 1.4rem; width: 1.4rem;">
<Certificate />
</div>
<span>{daysUntil(row['ssl']['valid_to'])} days left</span>
</div>
{:else}
<span>(none)</span>
{/if}
</td>
<td>{row.status}</td>
<td>{row.code}</td>
</tr>
{/each}
</tbody>
</Table>
{#if selectedSSL !== null}
<Dialog on:close={() => (selectedSSL = null)} title="SSL Certificate info">
<JsonViewer json={selectedSSL} />
</Dialog>
{/if}

View File

@@ -0,0 +1,37 @@
import type { RequestHandler } from './$types';
async function fetchImage(src: string) {
// const url = new URL(src);
const remoteUrl = String(src.match(/http:\/\/[a-z0-9\\.]+:8123\/.*/));
const url = new URL(remoteUrl);
const options = {
method: 'GET',
headers: {
'Content-Type': 'image/jpeg'
}
};
if (url === null) {
console.log('url is not valid');
return null;
}
return fetch(url.href, options)
.then((resp) => resp.blob())
.catch(() => null);
}
export const GET: RequestHandler = async ({ url }) => {
console.log('GET');
url.pathname = url.pathname.replace('/image/', '');
const res = await fetchImage(url.href);
// something went wrong
if (res === null) {
return new Response(null, { status: 204 });
}
return new Response(res);
};

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PageHeader from '$lib/components/PageHeader.svelte';
import Section from '$lib/components/Section.svelte';
import Table from '$lib/components/Table.svelte';
@@ -7,22 +8,11 @@
let { data }: { data: PageData } = $props();
const { routers } = data;
const columns = {
entryPoints: 'Entrypoints',
name: 'Name',
provider: 'Provider',
rule: 'Rule',
service: 'Service',
status: 'Status'
};
const links: string[] = routers.map((router) => `/network/${router.service}`);
const providers = [
...new Set(
routers.map((item) => item.provider).filter((provider) => typeof provider === 'string')
)
];
console.log(routers);
</script>
<PageHeader>Network</PageHeader>
@@ -42,7 +32,24 @@
</div>
</Section>
<Table title="Routers" description="Traefik routers available" {columns} data={routers} {links} />
<Table
title="Routers"
description="Traefik routers available"
columns={['Entrypoints', 'Name', 'Provider', 'Rule', 'Service', 'Status']}
>
<tbody slot="tbody">
{#each routers as route (route)}
<tr on:click={() => goto(`/network/${route.service}`)} class="link">
<td>{route.entryPoints}</td>
<td>{route.name}</td>
<td>{route.provider}</td>
<td>{route.rule}</td>
<td>{route.service}</td>
<td>{route.status}</td>
</tr>
{/each}
</tbody>
</Table>
</div>
<style lang="scss">

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte';
import JsonViewer from '$lib/components/JsonViewer.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -9,7 +10,4 @@
<PageHeader>Network: {router.service}</PageHeader>
<div>
<p>router:</p>
<pre><code>{JSON.stringify(router, null, 2)}</code></pre>
</div>
<JsonViewer json={router} />

View File

@@ -1,11 +1,30 @@
import type { PageServerLoad } from './$types';
import { fetchP1P } from '$lib/server/homeassistant';
import { currentFilament } from '$lib/server/filament';
import type { Entity } from '$lib/interfaces/homeassistant';
import { getAllFilament } from '$lib/server/database';
import type { Filament } from '$lib/interfaces/printer';
export const load: PageServerLoad = async (): Promise<{ p1p: Entity[]; filament: Filament[] }> => {
const p1p = await fetchP1P();
const filament = currentFilament();
interface PrinterState {
[key: string]: {
value: string;
unit?: string;
picture?: string;
};
}
export const load: PageServerLoad = async (): Promise<{
p1p: PrinterState;
filament: Filament[];
}> => {
let p1p;
let filament: Filament[];
try {
p1p = await fetchP1P();
filament = await getAllFilament();
} catch (error) {
console.error('error while fetching printer server props');
console.error(error);
}
return { p1p, filament };
};

View File

@@ -1,9 +1,12 @@
<script lang="ts">
import { capitalizeFirstLetter } from '$lib/utils/string';
import { formatTimeLeft } from '$lib/utils/conversion';
import PageHeader from '$lib/components/PageHeader.svelte';
import Section from '$lib/components/Section.svelte';
import Table from '$lib/components/Table.svelte';
import Progress from '$lib/components/Progress.svelte';
import Input from '$lib/components/Input.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import FormFilament from '$lib/components/forms/FormFilament.svelte';
import Finished from '$lib/icons/finished.svelte';
import Paused from '$lib/icons/paused.svelte';
@@ -15,53 +18,55 @@
import PrinterStopped from '$lib/icons/printer-stopped.svelte';
import NozzleTemperature from '$lib/icons/temperature-nozzle.svelte';
import BedTemperature from '$lib/icons/temperature-bed.svelte';
import Search from '$lib/icons/search.svelte';
import type { PageData } from './$types';
import type { Entity } from '$lib/interfaces/homeassistant';
import type { Filament } from '$lib/interfaces/printer';
import Weight from '$lib/icons/weight.svelte';
import Speed from '$lib/icons/speed.svelte';
import { onMount, onDestroy } from 'svelte';
import Time from '$lib/icons/time.svelte';
import { goto, invalidateAll } from '$app/navigation';
import PrinterImage from './section_image.svelte';
import PrinterAttributes from './section_printer_attributes.svelte';
import { formatDateIntl } from '$lib/utils/conversion';
import Length from '$lib/icons/Length.svelte';
interface PrinterState {
[key: string]: {
value: string;
unit?: string;
picture?: string;
};
}
let { data }: { data: PageData } = $props();
const p1p: Entity[] = data?.p1p;
const filament: Filament[] = data?.filament;
let printer: PrinterState = $state(data?.p1p);
let filamentFilter = $state('');
let secondsLeft = $state(0);
let open = $state(false);
let timeLeftInterval: ReturnType<typeof setInterval>;
const currentStage = p1p.filter(
(el) => el.entity_id === 'sensor.p1p_01s00c370700273_current_stage'
)[0];
const printStatus = p1p.filter(
(el) => el.entity_id === 'sensor.p1p_01s00c370700273_print_status'
)[0];
const bedTemp = p1p.filter(
(el) => el.entity_id === 'sensor.p1p_01s00c370700273_bed_temperature'
)[0];
const nozzleTemp = p1p.filter(
(el) => el.entity_id === 'sensor.p1p_01s00c370700273_nozzle_temperature'
)[0];
const totalUsage = p1p.filter(
(el) => el.entity_id === 'sensor.p1p_01s00c370700273_total_usage'
)[0];
const nozzleType = p1p.filter(
(el) => el.entity_id === 'sensor.p1p_01s00c370700273_nozzle_type'
)[0];
const nozzleSize = p1p.filter(
(el) => el.entity_id === 'sensor.p1p_01s00c370700273_nozzle_size'
)[0];
const bedType = p1p.filter(
(el) => el.entity_id === 'sensor.p1p_01s00c370700273_print_bed_type'
)[0];
const currentLayer = p1p.filter(
(el) => el.entity_id === 'sensor.p1p_01s00c370700273_current_layer'
)[0];
const totalLayer = p1p.filter(
(el) => el.entity_id === 'sensor.p1p_01s00c370700273_total_layer_count'
)[0];
const progress = p1p.filter(
(el) => el.entity_id === 'sensor.p1p_01s00c370700273_print_progress'
)[0];
const rawFilament: Filament[] = data?.filament || [];
let filament = $derived(
rawFilament
?.filter(
(f: Filament) =>
f.color.toLowerCase().includes(filamentFilter) ||
f.material.toLowerCase().includes(filamentFilter)
)
.sort((a, b) => (a.updated > b.updated ? -1 : 1))
);
console.log(p1p);
function reloadProps() {
invalidateAll().then(() => (printer = data?.p1p));
}
let columns = ['Hex', 'Color', 'Material', 'Weight', 'Count', 'Link'];
const links = filament.map((f) => `/printer/${f.Color.replaceAll(' ', '-').toLowerCase()}`);
// console.log(p1p);
const filamentLink = (f: Filament) =>
`/printer/filament/${f.color.replaceAll(' ', '-').toLowerCase()}`;
const iconDictState = { running: Printing, pause: Paused, failed: Stopped, finish: Finished };
const iconDictStage = {
idle: PrinterIdle,
printing: PrinterPrinting,
@@ -71,7 +76,16 @@
cleaning_nozzle_tip: PrinterPrinting,
homing_toolhead: PrinterPrinting
};
const iconDictState = { running: Printing, pause: Paused, failed: Stopped, finish: Finished };
function isObjKey<T>(key: PropertyKey, obj: T): key is keyof T {
return key in obj;
}
const stateToIcon = (key: string) => {
if (!isObjKey(key, iconDictStage)) return;
return iconDictStage[key];
};
interface FilamentUpdated {
date: Date;
@@ -88,6 +102,32 @@
year: 'numeric'
})
.toLowerCase();
function updateTimeLeft() {
if (secondsLeft <= 0) {
clearInterval(timeLeftInterval);
}
const now = new Date();
const diffMs = new Date(printer['end_time']?.value).getTime() - now.getTime();
secondsLeft = Math.max(Math.floor(diffMs / 1000), 0);
}
onMount(() => {
// only poll status updates if not idle
if (printer['print_status']?.value === 'idle') return;
updateTimeLeft();
timeLeftInterval = setInterval(updateTimeLeft, 1000);
const refreshStateInterval = setInterval(reloadProps, 5000);
return () =>
Promise.all([clearInterval(timeLeftInterval), clearInterval(refreshStateInterval)]);
});
onDestroy(() => {
clearInterval(timeLeftInterval);
});
</script>
<PageHeader>Printer</PageHeader>
@@ -97,110 +137,145 @@
title="Printer status"
description="Historical printer information, last prints and current status."
>
<div slot="top-left">
<button on:click={reloadProps}><span>Reload</span></button>
</div>
<div class="section-row">
<div class="section-element">
<label>Current stage</label>
<span
><span class="icon">
<svelte:component this={iconDictStage[currentStage.state]} />
</span>{currentStage.state}</span
<svelte:component this={stateToIcon(printer['current_stage']?.value || '')} />
</span>{printer['current_stage']?.value}</span
>
</div>
<div class="section-element">
<label>Bed temperature</label>
<label>Bed temp</label>
<span
><span class="icon"><BedTemperature /></span>{bedTemp.state}
{bedTemp.attributes.unit_of_measurement}</span
><span class="icon"><BedTemperature /></span>{printer['bed_temperature']?.value}
{printer['bed_temperature']?.unit}</span
>
</div>
<div class="section-element">
<label>Nozzle temperature</label>
<label>Nozzle temp</label>
<span
><span class="icon"><NozzleTemperature /></span>{nozzleTemp.state}
{nozzleTemp.attributes.unit_of_measurement}</span
><span class="icon"><NozzleTemperature /></span>{printer['nozzle_temperature']?.value}
{printer['nozzle_temperature']?.unit}</span
>
</div>
<div class="section-element">
<label>Speed profile</label>
<span
><span class="icon"><Speed /></span>{printer['speed_profile']?.value}
{printer['speed_profile']?.unit}</span
>
</div>
<div class="section-element">
<label>Print weight</label>
<span
><span class="icon" style="--size: 1.8rem"><Weight /></span>{printer['print_weight']
?.value}
{printer['print_weight']?.unit}</span
>
</div>
<div class="section-element">
<label>Print length</label>
<span
><span class="icon"><Length /></span>{printer['print_length']?.value}
{printer['print_length']?.unit}</span
>
</div>
<div class="section-element">
<label>Print status</label>
<span>
<span class={`icon ${printStatus?.state === 'running' ? 'spin' : ''}`}>
<svelte:component this={iconDictState[printStatus.state]} /></span
<span class={`icon ${printer['print_status']?.value === 'running' ? 'spin' : ''}`}>
<svelte:component this={iconDictState[printer['print_status']?.value]} /></span
>
{printStatus.state}
{printer['print_status']?.value}
</span>
</div>
<div class="section-element">
<label>Time left</label>
<span><span class="icon"><Time /></span>{formatTimeLeft(secondsLeft)}</span>
</div>
</div>
<div class="progress">
<Progress value={progress.state} />
{#if currentLayer.state !== totalLayer.state}
<span>Currently printing layer line {currentLayer.state} of {totalLayer.state}</span>
<Progress value={printer['print_progress']?.value} />
{#if printer['current_layer']?.value !== printer['total_layer_count']?.value}
<span
>Currently printing layer line {printer['current_layer']?.value} of {printer[
'total_layer_count'
]?.value}</span
>
{:else}
<span>Finished printing {currentLayer.state} of {totalLayer.state} layers!</span>
<span
>Finished printing {printer['current_layer']?.value} of {printer['total_layer_count']
?.value} layers!</span
>
{/if}
</div>
</Section>
<Section
title="Printer attributes"
description="Historical printer information, last prints and current status."
>
<div class="section-row">
<div class="section-element">
<label>Total print time</label>
<span>
{Math.floor(Number(totalUsage.state) * 10) / 10}
<!-- {formatDuration(totalUsage.state * 3600)} -->
{totalUsage.attributes.unit_of_measurement}</span
>
</div>
<PrinterImage data={printer} />
<div class="section-element">
<label>Nozzle Type</label>
<span
>{capitalizeFirstLetter(nozzleType.state.replaceAll('_', ' '))}
{nozzleType.attributes.unit_of_measurement}</span
>
</div>
<div class="section-element">
<label>Nozzle Size</label>
<span>{nozzleSize?.state} {nozzleSize.attributes.unit_of_measurement}</span>
</div>
<div class="section-element">
<label>Bed type</label>
<span
>{capitalizeFirstLetter(bedType?.state?.replaceAll('_', ' ') || 'not found')}
{bedType?.attributes.unit_of_measurement}</span
>
</div>
</div>
<img src="/printer.png" />
</Section>
<PrinterAttributes data={printer} />
<Table
title="Filaments"
description={`${filament.length} colors are currently in stock. Overview of currently stocked filament.`}
{columns}
columns={['Color', 'Details', 'Last bought']}
data={filament}
{links}
footer={`Last updated on ${lastUpdateFilament.title}`}
/>
>
<div slot="actions" class="filament-table-inputs">
<div>
<Input placeholder="Filter filaments" icon={Search} bind:value={filamentFilter} />
</div>
<style lang="scss">
img {
width: 120px;
position: absolute;
top: 1.2rem;
right: 1.2rem;
}
<button class="affirmative" on:click={() => (open = true)}><span>Add new</span></button>
</div>
<tbody slot="tbody">
{#each filament as row, i (row)}
<tr class="link" on:click={() => goto(filamentLink(row))}>
<td><span class="color" style={`background: ${row.hex}`} /></td>
<td class="info">
<h2>{row.material} in {row.color}</h2>
<div class="meta">
<span>Roll:&#9; {row.weight}</span>
<span>Color:&#9; {row.hex}</span>
<span>Last bought:&#9; {formatTimeLeft(row.updated / 1000)}</span>
</div>
</td>
<td>{formatDateIntl(new Date(row.updated * 1000))}</td>
</tr>
{/each}
</tbody>
</Table>
</div>
{#if open}
<Dialog
on: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."
>
<FormFilament on:close={() => (open = false)} />
</Dialog>
{/if}
<style lang="scss">
.section-element {
.icon {
display: inline-block;
@@ -234,4 +309,58 @@
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 {
height: 120px;
}
&.info {
display: table-cell;
vertical-align: middle;
padding-left: 25px;
h2 {
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.45em;
font-size: 1.1rem;
font-weight: 300;
color: #1c1b1b;
}
.meta {
display: flex;
gap: 0.3rem;
flex-direction: column;
color: #6a6a6a;
word-break: break-all;
}
}
}
.color {
--size: 4rem;
display: block;
width: var(--size);
height: var(--size);
border-radius: var(--border-radius, 1rem);
}
</style>

View File

@@ -1,12 +0,0 @@
import type { PageServerLoad } from './$types';
import { filamentByColor } from '$lib/server/filament';
export const load: PageServerLoad = async ({ params }) => {
let { id } = params;
if (id) {
id = id.replaceAll('-', ' ');
}
const filament = filamentByColor(id);
return { id, filament };
};

View File

@@ -0,0 +1,13 @@
import type { PageServerLoad } from './$types';
import { getFilamentByColor } from '$lib/server/database';
export const load = async ({ params }: Parameters<PageServerLoad>[0]) => {
let { id } = params;
if (id) {
id = id.replaceAll('-', ' ');
}
const filament = await getFilamentByColor(id);
console.log('fil:', filament);
return { id, filament: filament };
};

View File

@@ -6,11 +6,11 @@
const filament = data?.filament;
</script>
{#if filament !== null}
<PageHeader>Filament: {filament?.Color}</PageHeader>
{#if filament != null}
<PageHeader>Filament: {filament?.color}</PageHeader>
<div class="page">
<div class="color-block" style={`background: ${filament?.Hex}`}></div>
<div class="color-block" style={`background: ${filament?.hex}`}></div>
</div>
{:else}
<PageHeader>Filament not found!</PageHeader>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import LiveImage from '$lib/components/LiveImage.svelte';
import Section from '$lib/components/Section.svelte';
let { data }: { data: any } = $props();
</script>
<Section
title="Current print"
description="Historical printer information, last prints and current status."
>
<div class="images">
<div>
<h2>Camera</h2>
<LiveImage imageUrl={`http://homeassistant.schleppe:8123${data['camera']?.picture}`} />
</div>
<div>
<h2>Model</h2>
<img src={`http://homeassistant.schleppe:8123${data['cover_image']?.picture}`} />
</div>
<div>
<h2>Pick image</h2>
<img src={`http://homeassistant.schleppe:8123${data['pick_image']?.picture}`} />
</div>
</div>
</Section>
<style lang="scss">
.images {
display: flex;
gap: 2rem;
flex-wrap: wrap;
img {
width: 300px;
}
}
</style>

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import Section from '$lib/components/Section.svelte';
import { capitalizeFirstLetter } from '$lib/utils/string';
let { data }: { data: any } = $props();
</script>
<Section
title="Printer attributes"
description="Historical printer information, last prints and current status."
>
<div class="section-row">
<div class="section-element">
<label>Total print time</label>
<span>
{Math.floor(Number(data['total_usage']?.value) * 10) / 10}
<!-- {formatDuration(totalUsage.value * 3600)} -->
{data['total_usage']?.unit}</span
>
</div>
<div class="section-element">
<label>Total print length</label>
<span>
{Math.floor(Number(data['print_length']?.value) * 10) / 10}
{data['print_length']?.unit}</span
>
</div>
<div class="section-element">
<label>Nozzle Type</label>
<span
>{capitalizeFirstLetter(data?.['nozzle_type']?.value?.replaceAll('_', ' '))}
{data['nozzle_type']?.unit}</span
>
</div>
<div class="section-element">
<label>Nozzle Size</label>
<span>{data['nozzle_size']?.value} {data['nozzle_size']?.unit}</span>
</div>
<div class="section-element">
<label>Bed type</label>
<span
>{capitalizeFirstLetter(data['print_bed_type']?.value?.replaceAll('_', ' ') || 'not found')}
{data['print_bed_type']?.unit}</span
>
</div>
<div class="section-element">
<label>SD Card status</label>
<span
>{data['sd_card_status']?.value}
{data['sd_card_status']?.unit}</span
>
</div>
<div class="section-element">
<label>WiFi signal</label>
<span>{data['wi_fi_signal']?.value} {data['wi_fi_signal']?.unit}</span>
</div>
</div>
<div class="printer-image">
<img src="/printer.png" />
</div>
</Section>
<style lang="scss">
.printer-image img {
width: 120px;
position: absolute;
top: 1.2rem;
right: 1.2rem;
}
</style>

View File

@@ -1,11 +1,64 @@
<script>
<script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte';
import Section from '$lib/components/Section.svelte';
import ThumbnailButton from '$lib/components/ThumbnailButton.svelte';
interface Site {
title: string;
image: string;
link: string;
background?: string;
color?: string;
}
const sites: Array<Site> = [
{
title: 'Grafana',
image:
'https://www.stackhero.io/assets/src/images/servicesLogos/openGraphVersions/grafana.png',
link: 'https://grafana.schleppe.cloud',
background: '#082448',
color: 'white'
},
{
title: 'Prometheus',
image: 'https://prometheus.io/_next/static/media/prometheus-logo.7aa022e5.svg',
link: 'http://prome.schleppe:9090'
},
{
title: 'Traefik',
image: 'https://storage.googleapis.com/schleppe-files/Traefik.logo_shape_bordered.png',
link: 'https://grafana.schleppe.cloud',
background: '#30A4C2',
color: '#343A40'
},
{
title: 'Kibana',
image: 'https://marketplace-assets.digitalocean.com/logos/sharklabs-kibana.svg',
link: 'https://kibana.schleppe.cloud'
},
{
title: 'HASS',
image:
'https://upload.wikimedia.org/wikipedia/en/thumb/4/49/Home_Assistant_logo_%282023%29.svg/2048px-Home_Assistant_logo_%282023%29.svg.png',
link: 'http://homeassistant.schleppe:8123'
}
];
</script>
<PageHeader>Sites</PageHeader>
<div class="section-wrapper">
{#each sites as site}
<ThumbnailButton
title={site.title}
image={site.image}
background={site.background}
color={site.color}
link={site.link}
/>
{/each}
<Section
title="Expose HTTP traffic"
description="You can reach your Application on a specific Port you configure, redirecting all your domains to it. You can make it Private by disabling HTTP traffic."
@@ -26,3 +79,10 @@
description="Connected services can communicate with your application over the private network."
/>
</div>
<style lang="scss">
.section-wrapper {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
</style>

Binary file not shown.

Binary file not shown.

View File

@@ -1,3 +1,9 @@
@font-face {
font-family: 'Reckless Neue';
font-style: normal;
src: url('/fonts/RecklessNeue-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
@@ -6,6 +12,22 @@
font-style: normal;
}
@font-face {
font-family: 'Stop';
font-style: normal;
src: url('/fonts/StopSN-Display.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
}
@font-face {
font-family: 'Norman';
font-style: normal;
src: url('/fonts/NormanVariable-Regular.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
}
:root {
--bg: #f9f5f3;
--color: #1c1819;
@@ -37,7 +59,10 @@ a:visited {
}
h1 {
font-family: 'Reckless Neue';
font-family: 'Stop', 'Reckless Neue';
font-family: 'Norman', 'Reckless Neue';
letter-spacing: 2.2px;
font-weight: bold !important;
}
h2 {
@@ -69,27 +94,43 @@ button {
border: none;
position: relative;
background: transparent;
height: unset;
height: 100%;
border-radius: 0.5rem;
display: inline-block;
text-decoration: none;
cursor: pointer;
padding: 0 0.5rem;
flex: 1;
}
button span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 1.5rem;
height: 100%;
min-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;
transition: all ease-in-out 0.2s;
background-color: white;
}
button.affirmative span {
background-color: #1c1819;
color: white;
}
button.affirmative:hover span {
background-color: #363132;
}
button:disabled {
cursor: not-allowed;
}
button::after {
@@ -107,7 +148,49 @@ button::after {
button:hover span {
border-color: #cab2aa;
background: #f9f5f3 !important;
background: #f9f5f3;
}
table {
width: 100%;
border-collapse: collapse;
font-family: sans-serif;
border-radius: 8px;
overflow: hidden;
}
table th,
table td {
padding: 12px;
text-align: left;
transition: background-color 0.25s;
}
table th {
font-weight: 500;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-stretch: 2px;
border-bottom: 1px solid #eaddd5;
}
table td {
padding-top: 1rem;
padding-bottom: 1rem;
display: table-cell;
}
table tr:not(table tr:last-of-type) {
border-bottom: 1px solid #eaddd5;
}
table tr:hover > td {
background-color: var(--highlight);
background-color: #f5ede9;
}
table tr.link {
cursor: pointer;
}
.main-container {
@@ -126,7 +209,9 @@ button:hover span {
.section-row {
display: flex;
gap: 2rem;
flex-wrap: wrap;
column-gap: 2rem;
row-gap: 1rem;
}
.section-element {

View File

@@ -1,4 +1,5 @@
import adapter from '@sveltejs/adapter-node';
// import adapter from "svelte-adapter-bun";
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */

29
varnish/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM debian:bullseye
# Install dependencies
RUN apt-get update && apt-get install -y \
curl gnupg apt-transport-https lsb-release \
build-essential git autoconf libtool python3-docutils \
libmhash-dev pkg-config
# Add Varnish Software repo
RUN curl -fsSL https://packagecloud.io/varnishcache/varnish73/gpgkey | gpg --dearmor -o /usr/share/keyrings/varnish.gpg && \
echo "deb [signed-by=/usr/share/keyrings/varnish.gpg] https://packagecloud.io/varnishcache/varnish73/debian/ $(lsb_release -cs) main" \
> /etc/apt/sources.list.d/varnish.list
# Install Varnish + dev headers
RUN apt-get update && apt-get install -y varnish varnish-dev
# Build libvmod-digest
RUN git clone https://github.com/varnish/libvmod-digest.git /opt/libvmod-digest && \
cd /opt/libvmod-digest && \
./autogen.sh && \
./configure VARNISHSRC=/usr/include/varnish && \
make && make install
COPY default.vcl /etc/varnish/default.vcl
EXPOSE 6081
CMD ["varnishd", "-F", "-f", "/etc/varnish/default.vcl", "-a", ":6081", "-s", "malloc,512m"]

View File

@@ -685,6 +685,11 @@
"@typescript-eslint/types" "8.29.1"
eslint-visitor-keys "^4.2.0"
"@zerodevx/svelte-json-view@^1.0.11":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@zerodevx/svelte-json-view/-/svelte-json-view-1.0.11.tgz#39d33dd066f5442ad58b5dffcf8f958a6b80e4ea"
integrity sha512-mIjj0H1al/P4FPlbeDoiey93lNEUqBEAe5LIdD5GttZfEYt3awexD2lHwKNfUeY4jHizOJkoWTPN/2iO0GBqpw==
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -1224,6 +1229,16 @@ globals@^14.0.0:
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
globalyzer@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/globalyzer/-/globalyzer-0.1.0.tgz#cb76da79555669a1519d5a8edf093afaa0bf1465"
integrity sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==
globrex@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098"
integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==
gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
@@ -1963,6 +1978,11 @@ sprintf-js@^1.1.3:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a"
integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==
sqlite@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/sqlite/-/sqlite-5.1.1.tgz#26a6a200fdac490643880af2b6cb3940ada59274"
integrity sha512-oBkezXa2hnkfuJwUo44Hl9hS3er+YFtueifoajrgidvqsJRQFpc5fKoAkAor1O5ZnLoa28GBScfHXs8j0K358Q==
stream-buffers@^3.0.2:
version "3.0.3"
resolved "https://registry.yarnpkg.com/stream-buffers/-/stream-buffers-3.0.3.tgz#9fc6ae267d9c4df1190a781e011634cac58af3cd"
@@ -1992,6 +2012,13 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
svelte-adapter-bun@^0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/svelte-adapter-bun/-/svelte-adapter-bun-0.5.2.tgz#92c9984bb1555454e35e7732196cbf1cd5cedf40"
integrity sha512-xEtFgaal6UgrCwwkSIcapO9kopoFNUYCYqyKCikdqxX9bz2TDYnrWQZ7qBnkunMxi1HOIERUCvTcebYGiarZLA==
dependencies:
tiny-glob "^0.2.9"
svelte-check@^4.0.0:
version "4.1.5"
resolved "https://registry.yarnpkg.com/svelte-check/-/svelte-check-4.1.5.tgz#afdb3f8050c123064124d5aa7821365c7befa7a4"
@@ -2064,6 +2091,14 @@ tar@^7.0.0:
mkdirp "^3.0.1"
yallist "^5.0.0"
tiny-glob@^0.2.9:
version "0.2.9"
resolved "https://registry.yarnpkg.com/tiny-glob/-/tiny-glob-0.2.9.tgz#2212d441ac17928033b110f8b3640683129d31e2"
integrity sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==
dependencies:
globalyzer "0.1.0"
globrex "^0.1.2"
tmp-promise@^3.0.2:
version "3.0.3"
resolved "https://registry.yarnpkg.com/tmp-promise/-/tmp-promise-3.0.3.tgz#60a1a1cc98c988674fcbfd23b6e3367bdeac4ce7"