20 Commits

Author SHA1 Message Date
14079b25b7 corrects background of sidebar when dark-mode on mobile 2025-11-17 18:30:59 +01:00
5bc57ba497 print fullscreen image takes full desktop width 2025-11-14 23:31:39 +01:00
0991d1a013 use axfr DNS request to get all zonal entries 2025-10-25 23:10:24 +02:00
9c5d8f3bdc Globally search between pages with meta + J 2025-10-25 23:03:34 +02:00
5842a16c9b use prefered color scheme for dark/light mode 2025-10-14 01:12:43 +02:00
2c8ef59c19 update globalSearch layout & styling - including darkmode 2025-10-14 00:50:34 +02:00
6678cfda7d darkmode 2025-10-14 00:24:59 +02:00
c8f828bfb0 misc linting, error handling and code-splitting 2025-10-13 21:13:42 +02:00
195cc47368 decrease main container padding on smaller screens 2025-10-13 21:11:10 +02:00
eb368f9860 selected defaults to undefined & type unkown icon prop 2025-10-13 21:08:51 +02:00
d26aa8c9cb server node, vm & lxc details pages at unique routes 2025-10-13 21:05:26 +02:00
471b13739d global search component 2025-10-13 20:29:26 +02:00
d0e107f644 kubernetes deployments for varnish & app 2025-09-04 01:41:30 +02:00
630ceb2473 compile varnish tmpl from docker-entrypoint script
also updates kubernetes resources to separate app & varnish into two different deployments
2025-09-03 00:23:46 +02:00
d233c8081a only show section header if values exist 2025-09-03 00:23:46 +02:00
bbd8c1e40c ignore casing when looking for printer in hass output 2025-09-03 00:23:46 +02:00
57fb2febf6 decrease image refresh to 1 second 2025-09-03 00:23:46 +02:00
a5b9823b10 moved sites to postgres database, use button to add more 2025-09-03 00:23:46 +02:00
a2a4fdd770 separated db & function code (filament) 2025-09-03 00:23:46 +02:00
9034c23d84 new icons & script for converting SVGs to svelte files 2025-09-03 00:15:57 +02:00
99 changed files with 2873 additions and 879 deletions

View File

@@ -61,61 +61,6 @@ trigger:
depends_on: depends_on:
- Build - Build
---
kind: pipeline
type: docker
name: Publish
platform:
os: linux
arch: amd64
kind: pipeline
type: docker
name: config-check
steps:
- name: check-config
image: alpine/git
commands:
- git fetch --no-tags --depth=2
- |
if git diff --quiet HEAD^ HEAD -- varnish/default.vcl; then
echo "No changes in varnish config file, skipping..."
exit 78 # exit code 78 = skip in Drone
else
echo "Changes detected in varnish config"
fi
- name: Publish varnish to ghcr
image: plugins/docker
settings:
registry: ghcr.io
repo: ghcr.io/kevinmidboe/varnish-infra-map
contexT: varnish
dockerfile: Dockerfile
compress: true
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GHCR_UPLOAD_TOKEN
build_args_from_env:
-
tags:
- latest
- ${DRONE_COMMIT_SHA}
trigger:
event:
include:
- push
exclude:
- pull_request
branch:
- main
- update
depends_on:
- Build
--- ---
kind: pipeline kind: pipeline
type: docker type: docker
@@ -136,7 +81,7 @@ steps:
commands: commands:
- mkdir -p /root/.kube - mkdir -p /root/.kube
- echo "IMAGE=ghcr.io/kevinmidboe/${DRONE_REPO_NAME}:${DRONE_COMMIT_SHA}" > /root/.kube/.env - echo "IMAGE=ghcr.io/kevinmidboe/${DRONE_REPO_NAME}:${DRONE_COMMIT_SHA}" > /root/.kube/.env
- echo "VARNISH_IMAGE=ghcr.io/kevinmidboe/varnish-${DRONE_REPO_NAME}" >> /root/.kube/.env - echo "VARNISH_IMAGE=ghcr.io/kevinmidboe/varnish-${DRONE_REPO_NAME}:latest" >> /root/.kube/.env
- echo "NAMESPACE=${DRONE_REPO_NAME}" >> /root/.kube/.env - echo "NAMESPACE=${DRONE_REPO_NAME}" >> /root/.kube/.env
- 'curl -s - 'curl -s
-H "X-Vault-Token: $VAULT_TOKEN" -H "X-Vault-Token: $VAULT_TOKEN"
@@ -184,8 +129,57 @@ depends_on:
volumes: volumes:
- name: kube-config - name: kube-config
temp: {} temp: {}
---
kind: pipeline
type: docker
name: Publish varnish
platform:
os: linux
arch: amd64
steps:
- name: Check for varnish changes
image: alpine/git
commands:
- git fetch --no-tags --depth=2
- |
if git diff-tree --no-commit-id --name-only -r HEAD | grep -qE '(\.drone.yml|(varnish/.+(vcl|tmpl)(\n|$)))'; then
echo "Changes detected in varnish config"
else
echo "No changes in varnish config file, skipping..."
exit 78 # exit code 78 = skip in Drone
fi
- name: Publish varnish image to ghcr
image: plugins/docker
settings:
registry: ghcr.io
repo: ghcr.io/kevinmidboe/varnish-infra-map
context: varnish
dockerfile: varnish/Dockerfile
compress: true
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GHCR_UPLOAD_TOKEN
tags:
- latest
- ${DRONE_COMMIT_SHA}
trigger:
event:
include:
- push
exclude:
- pull_request
branch:
- main
- update
--- ---
kind: signature kind: signature
hmac: 01caa41521eac62356f6fc941cdd489dae8e2c4249bdb4e4dc1a32e101c639b7 hmac: b4b6a98b76fdf3cf297b46cf986a3d46f3d4050e623f2c769267181c7075a6ca
... ...

View File

@@ -0,0 +1,9 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: varnish-config
namespace: ${NAMESPACE}
data:
PROXY_HOST: ${PROXY_HOST}
IMAGE_HOST: ${IMAGE_HOST}

View File

@@ -1,8 +0,0 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: varnish-vcl
namespace: ${NAMESPACE}
binaryData:
default.vcl: dmNsIDQuMDsKCmltcG9ydCBzdGQ7CmltcG9ydCBkaWdlc3Q7CgojIERlZmluZSBiYWNrZW5kIHBvaW50aW5nIHRvIEhvbWUgQXNzaXN0YW50IElQCmJhY2tlbmQgaGFzc19iYWNrZW5kIHsKICAgIC5ob3N0ID0gIjEwLjAuMC44MiI7CiAgICAucG9ydCA9ICI4MTIzIjsKfQoKc3ViIHZjbF9yZWN2IHsKICAgICMgSGFuZGxlIENPUlMgcHJlZmxpZ2h0CiAgICBpZiAocmVxLm1ldGhvZCA9PSAiT1BUSU9OUyIpIHsKICAgICAgICByZXR1cm4gKHN5bnRoKDIwNCwgIlByZWZsaWdodCIpKTsKICAgIH0KCiAgICAjIFJld3JpdGUgaW1hZ2UgVVJMCiAgICBpZiAocmVxLnVybCB+ICJeL2ltYWdlLyIpIHsKICAgICAgICAjIEV4dHJhY3QgZXZlcnl0aGluZyBhZnRlciAvaW1hZ2UvIGFuZCBzdG9yZSBpdAogICAgICAgIHNldCByZXEuaHR0cC5YLUltYWdlLVVSTCA9IHJlZ3N1YihyZXEudXJsLCAiXi9pbWFnZS8oLiopIiwgIlwxIik7CiAgICAgICAgIyBSZXdyaXRlIHJlcS51cmwgdG8gbWF0Y2ggYmFja2VuZCBleHBlY3RhdGlvbnMKICAgICAgICBzZXQgcmVxLnVybCA9IHJlZ3N1YihyZXEuaHR0cC5YLUltYWdlLVVSTCwgIl5odHRwOi8vW14vXSsiLCAiIik7CiAgICB9CgogICAgIyBSZW1vdmUgY29va2llcyBzbyBjb250ZW50IGlzIGNhY2hlYWJsZQogICAgdW5zZXQgcmVxLmh0dHAuQ29va2llOwp9CgpzdWIgdmNsX3N5bnRoIHsKICAgIGlmIChyZXNwLnN0YXR1cyA9PSAyMDQpIHsKICAgICAgICBzZXQgcmVzcC5odHRwLkFjY2Vzcy1Db250cm9sLUFsbG93LU9yaWdpbiA9ICIqIjsKICAgICAgICBzZXQgcmVzcC5odHRwLkFjY2Vzcy1Db250cm9sLUFsbG93LU1ldGhvZHMgPSAiR0VULCBPUFRJT05TIjsKICAgICAgICBzZXQgcmVzcC5odHRwLkFjY2Vzcy1Db250cm9sLUFsbG93LUhlYWRlcnMgPSAiQ29udGVudC1UeXBlLCBYLUNhY2hlLUlEIjsKICAgICAgICBzZXQgcmVzcC5odHRwLkNvbnRlbnQtTGVuZ3RoID0gIjAiOwogICAgICAgIHJldHVybiAoZGVsaXZlcik7CiAgICB9CgogICAgaWYgKHJlc3Auc3RhdHVzID09IDMwNCkgewogICAgICAgIHNldCByZXNwLmh0dHAuRVRhZyA9IHJlcS5odHRwLklmLU5vbmUtTWF0Y2g7CiAgICAgICAgc2V0IHJlc3AuaHR0cC5Db250ZW50LUxlbmd0aCA9ICIwIjsKICAgICAgICByZXR1cm4gKGRlbGl2ZXIpOwogICAgfQp9CgpzdWIgdmNsX2JhY2tlbmRfZmV0Y2ggewogICAgIyBBbHdheXMgdXNlIHRoZSBIQVNTIGJhY2tlbmQKICAgIHNldCBiZXJlcS5iYWNrZW5kID0gaGFzc19iYWNrZW5kOwoKICAgICMgU2V0IHByb3BlciBIb3N0IGhlYWRlciBmcm9tIG9yaWdpbmFsIFVSTAogICAgIyBpZiAoYmVyZXEuaHR0cC5YLUltYWdlLVVSTCkgewogICAgIyAgICAgc2V0IGJlcmVxLmh0dHAuSG9zdCA9IHJlZ3N1YihiZXJlcS5odHRwLlgtSW1hZ2UtVVJMLCAiXmh0dHA6Ly8oW14vXSspLioiLCAiXDEiKTsKICAgICMgICAgIHNldCBiZXJlcS5odHRwLkhvc3QgPSByZWdzdWIoYmVyZXEuaHR0cC5Ib3N0LCAiOlswLTldKyQiLCAiIik7CiAgICAjIH0KfQoKc3ViIHZjbF9iYWNrZW5kX3Jlc3BvbnNlIHsKICAgIHNldCBiZXJlc3AudHRsID0gMXM7CiAgICBzZXQgYmVyZXNwLmdyYWNlID0gNjBzOwogICAgc2V0IGJlcmVzcC5rZWVwID0gNjBzOwoKICAgICMgRW5zdXJlIEVUYWcgaXMgcGFzc2VkIHRvIGNsaWVudAogICAgaWYgKGJlcmVzcC5odHRwLkVUYWcpIHsKICAgICAgICBzZXQgYmVyZXNwLmh0dHAuWC1DYWNoZS1FVGFnID0gYmVyZXNwLmh0dHAuRVRhZzsKICAgIH0gZWxzZSB7CiAgICAgICAgIyBPcHRpb25hbDogZ2VuZXJhdGUgb25lIGlmIG5vdCBwcm92aWRlZAogICAgICAgICMgc2V0IGJlcmVzcC5odHRwLkVUYWcgPSBkaWdlc3QuaGFzaF9tZDUoYmVyZXNwLmJvZHkpOwogICAgICAgIHNldCBiZXJlc3AuaHR0cC5FVGFnID0gYmVyZXNwLmh0dHAuQ29udGVudC1MZW5ndGg7CiAgICAgICAgc2V0IGJlcmVzcC5odHRwLlgtQ2FjaGUtRVRhZyA9IGJlcmVzcC5odHRwLkVUYWc7CiAgICB9Cn0KCnN1YiB2Y2xfaGl0IHsKICAgIGlmIChvYmoudHRsIDwgMHMgJiYgc3RkLmhlYWx0aHkocmVxLmJhY2tlbmRfaGludCkpIHsKICAgICAgICByZXR1cm4gKGRlbGl2ZXIpOwogICAgfQp9CgpzdWIgdmNsX2RlbGl2ZXIgewogICAgdW5zZXQgcmVzcC5odHRwLlgtSW1hZ2UtVVJMOwogICAgc2V0IHJlc3AuaHR0cC5BY2Nlc3MtQ29udHJvbC1BbGxvdy1PcmlnaW4gPSAiKiI7CgogICAgIyBIYW5kbGUgY29uZGl0aW9uYWwgcmVxdWVzdCB3aXRoIEVUYWcKICAgIGlmICgKICAgICAgICByZXEuaHR0cC5JZi1Ob25lLU1hdGNoICYmCiAgICAgICAgcmVxLmh0dHAuSWYtTm9uZS1NYXRjaCA9PSByZXNwLmh0dHAuRVRhZwogICAgKSB7CiAgICAgICAgcmV0dXJuIChzeW50aCgzMDQpKTsKICAgIH0KfQo=

View File

@@ -0,0 +1,46 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
labels:
app: infra-map
name: infra-map
namespace: ${NAMESPACE}
spec:
replicas: 2
selector:
matchLabels:
app: infra-map
template:
metadata:
labels:
app: infra-map
spec:
containers:
- name: infra-map
image: ${IMAGE}
imagePullPolicy: IfNotPresent
resources:
limits:
cpu: 300m
memory: 828Mi
requests:
cpu: 250m
memory: 64Mi
env:
- name: ORIGIN
value: http://infra-map.infra-map.svc.cluster.local:3000
- name: PROTOCOL_HEADER
value: x-forwarded-proto
- name: HOST_HEADER
value: x-forwarded-host
- name: PORT_HEADER
value: x-forwarded-port
- name: ENV
value: production
envFrom:
- secretRef:
name: secret-env-values
imagePullSecrets:
- name: ghcr-login-secret

View File

@@ -0,0 +1,40 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
labels:
app: varnish
name: varnish
namespace: ${NAMESPACE}
spec:
replicas: 2
selector:
matchLabels:
app: varnish
template:
metadata:
labels:
app: varnish
spec:
containers:
- command:
- /usr/local/bin/docker-entrypoint.sh
envFrom:
- configMapRef:
name: varnish-config
image: ghcr.io/kevinmidboe/varnish-infra-map:latest
imagePullPolicy: Always
name: varnish
resources:
limits:
cpu: 900m
memory: 828Mi
requests:
cpu: 250m
memory: 64Mi
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
imagePullSecrets:
- name: ghcr-login-secret
dnsPolicy: ClusterFirst

View File

@@ -1,56 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
labels:
app: infra-map
name: infra-map
namespace: ${NAMESPACE}
spec:
replicas: 2
selector:
matchLabels:
app: infra-map
template:
metadata:
labels:
app: infra-map
spec:
containers:
- image: ${IMAGE}
imagePullPolicy: IfNotPresent
name: infra-map
envFrom:
- secretRef:
name: secret-env-values
resources:
limits:
cpu: 900m
memory: 828Mi
requests:
cpu: 250m
memory: 64Mi
- image: ${VARNISH_IMAGE}:latest
imagePullPolicy: IfNotPresent
name: varnish
command: ['varnishd']
args: ['-F', '-f', '/etc/varnish/default.vcl', '-a', ':6081', '-s', 'malloc,512m']
volumeMounts:
- name: varnish-vcl
mountPath: /etc/varnish/default.vcl
subPath: default.vcl
resources:
limits:
cpu: 900m
memory: 828Mi
requests:
cpu: 250m
memory: 64Mi
restartPolicy: Always
imagePullSecrets:
- name: ghcr-login-secret
volumes:
- name: varnish-vcl
configMap:
name: varnish-vcl

View File

@@ -12,7 +12,7 @@ spec:
paths: paths:
- backend: - backend:
service: service:
name: infra-map-service name: varnish
port: port:
number: 80 number: 80
path: / path: /

View File

@@ -1,20 +1,37 @@
---
apiVersion: v1
kind: Service
metadata:
labels:
app: varnish
name: varnish
namespace: ${NAMESPACE}
spec:
ports:
- port: 80
name: http-varnish
protocol: TCP
targetPort: 6081
selector:
app: varnish
sessionAffinity: None
type: ClusterIP
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
labels: labels:
app: infra-map app: infra-map
name: infra-map-service name: infra-map
namespace: ${NAMESPACE} namespace: ${NAMESPACE}
spec: spec:
ports: ports:
- port: 80 - port: 80
name: http name: http-app
protocol: TCP protocol: TCP
targetPort: 6081 targetPort: 3000
selector: selector:
app: infra-map app: infra-map
sessionAffinity: None sessionAffinity: None
type: ClusterIP type: ClusterIP
status:
loadBalancer: {}

View File

@@ -5,10 +5,10 @@ services:
build: build:
context: varnish context: varnish
dockerfile: Dockerfile dockerfile: Dockerfile
args: environment:
# sets build variables. Overridden by env, but has sane defaults # sets environment variables. Overridden by env, but has sane defaults
IMAGE_HOST: ${IMAGE_HOST:-homeassistant.local} IMAGE_HOST: ${IMAGE_HOST:-homeassistant.local}
PROXY_HOST: ${PROXY_HOST:-app} PROXY_HOST: ${PROXY_HOST:-app}
ports: ports:
- '6081:6081' - '6081:6081'
depends_on: depends_on:
@@ -20,6 +20,7 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
env_file: .env # sets container's environment env_file: .env # sets container's environment
environment: environment:
- ORIGIN=http://localhost:3000
- NODE_ENV=production - NODE_ENV=production
- PROTOCOL_HEADER=x-forwarded-proto - PROTOCOL_HEADER=x-forwarded-proto
- HOST_HEADER=x-forwarded-host - HOST_HEADER=x-forwarded-host

53
scripts/icon-converter.js Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env node
/**
* Usage: node convert-svg-to-svelte.js [inputDir] [outputDir]
* Defaults: ./svgs ./svelte
*/
import fs from 'fs';
import path from 'path';
const INPUT_DIR = process.argv[2] || '../svgs';
const OUTPUT_DIR = process.argv[3] || '../src/lib/icons';
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
function processSvg(svgContent) {
// Strip XML/DOCTYPE
let out = svgContent.replace(/<\?xml[\s\S]*?\?>\s*/i, '').replace(/<!DOCTYPE[\s\S]*?>\s*/i, '');
// Remove ALL comments
out = out.replace(/<!--[\s\S]*?-->\s*/g, '');
// Remove <g id="icomoon-ignore"></g> with any whitespace between tags
out = out.replace(/<g\s+id=(["'])icomoon-ignore\1\s*>\s*<\/g>\s*/gi, '');
// Ensure only width="100%" height="100%" on the <svg> tag
out = out.replace(/<svg\b[^>]*>/i, (match) => {
let tag = match
.replace(/\s+(width|height)\s*=\s*"[^"]*"/gi, '')
.replace(/\s+(width|height)\s*=\s*'[^']*'/gi, '');
return tag.replace(/>$/, ' width="100%" height="100%">');
});
// Prepend the single license comment
out = '<!-- generated by icomoon.io - licensed Lindua icon -->\n' + out.replace(/^\s+/, '');
return out;
}
function convertSvgs(inputDir = INPUT_DIR, outputDir = OUTPUT_DIR) {
if (!fs.existsSync(inputDir)) {
console.warn(`Input directory not found: ${inputDir}`);
return;
}
const files = fs.readdirSync(inputDir).filter((f) => f.toLowerCase().endsWith('.svg'));
files.forEach((file) => {
const src = path.join(inputDir, file);
const dest = path.join(outputDir, file.replace(/\.svg$/i, '.svelte'));
const svgContent = fs.readFileSync(src, 'utf8');
const processed = processSvg(svgContent);
fs.writeFileSync(dest, processed, 'utf8');
console.log(`Converted: ${file} -> ${path.basename(dest)}`);
});
}
convertSvgs();

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { onMount } from "svelte";
function systemDarkModeEnabled() {
const computedStyle = window.getComputedStyle(document.body);
if (computedStyle?.colorScheme != null) {
return computedStyle.colorScheme.includes("dark");
}
return false;
}
function updateBodyClass() {
document.body.className = darkmode ? "dark" : "light";
}
let darkmode = $state(false);
const darkmodeToggleIcon = $derived(darkmode ? "🌝" : "🌚");
function toggleDarkmode() {
darkmode = !darkmode;
updateBodyClass()
}
onMount(() => {
darkmode = systemDarkModeEnabled()
updateBodyClass()
})
</script>
<div class="darkToggle">
<span on:click={toggleDarkmode}>{ darkmodeToggleIcon }</span>
</div>
<style lang="scss" scoped>
.darkToggle {
height: 25px;
width: 25px;
cursor: pointer;
position: fixed;
margin-bottom: 1.5rem;
margin-right: 2px;
bottom: 0;
right: 0;
z-index: 10;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,345 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import Dialog from './Dialog.svelte';
import Input from './Input.svelte';
import { allRoutes } from '$lib/remote/filesystem.remote.ts';
import type { PageRoute } from '$lib/remote/filesystem.remote.ts';
type ShortcutHandler = (event: KeyboardEvent) => void;
interface Shortcut {
keys: string[];
handler: ShortcutHandler;
description?: string;
}
interface MinimalElement {
name: string;
link: string;
}
interface OverlayData {
type: 'elements' | 'pages' | null;
content: MinimalElement | unknown;
}
class KeyboardShortcutManager {
private shortcuts: Shortcut[] = [];
constructor() {
window.addEventListener('keydown', this.handleKeydown);
}
register(shortcut: Shortcut) {
this.shortcuts.push(shortcut);
}
unregisterAll() {
this.shortcuts = [];
window.removeEventListener('keydown', this.handleKeydown);
}
private handleKeydown = (event: KeyboardEvent) => {
const pressedKeys = [
event.metaKey ? 'Meta' : '',
event.ctrlKey ? 'Control' : '',
event.shiftKey ? 'Shift' : '',
event.altKey ? 'Alt' : '',
event.key.toUpperCase()
].filter(Boolean);
for (const shortcut of this.shortcuts) {
if (this.isMatch(shortcut.keys, pressedKeys)) {
event.preventDefault();
shortcut.handler(event);
return;
}
}
// some other key, but not overlay is not open. Nothing to do
if (!overlayStore.type) return;
// listen for text, any letter should reset focusIndex
const singleLetter = (event.key.length == 1 && event.key.match(/\D/)) || 0 > 0;
if (singleLetter) {
updateFocus(0);
}
// listen for number as shortcut actions
const digit = event.key.match(/\d/)?.[0];
if (digit?.length && digit?.length > 0) {
setTimeout(() => {
filterString = String(filterString)?.replaceAll(digit, '');
}, 1);
updateFocus(Number(digit) - 1);
}
// listen for arrow keys
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const direction = event.key === 'ArrowDown' ? 1 : -1;
updateFocus(focusIndex + 1 * direction);
}
// listen for enter key
if (event.key === 'Enter' && filteredchildren.length > 0) {
const { link, path } = filteredchildren[focusIndex];
hideOverlay();
openElement(link || path);
}
};
private isMatch(shortcutKeys: string[], pressedKeys: string[]) {
return (
shortcutKeys.length === pressedKeys.length &&
shortcutKeys.every((key) => pressedKeys.includes(key))
);
}
}
let overlayStore: OverlayData = $state({ type: null, content: null });
let pages: Array<PageRoute> = $state([]);
function showOverlay(type: 'elements' | 'pages', content: unknown) {
if (type === overlayStore.type) return hideOverlay();
overlayStore = { type, content };
focusSearchInput();
filterString = '';
updateFocus(0);
}
function hideOverlay() {
overlayStore = { type: null, content: null };
}
async function resolvePages() {
pages = await allRoutes();
}
// call as soon as possible, even if blocking
resolvePages();
// setup managers
let manager: KeyboardShortcutManager;
const className = 'search-container';
// search & filter
let filterString = $state('');
let focusIndex = $state(0);
let filteredchildren = $derived.by(() => {
return overlayStore.content?.filter((a) => a?.name.toLowerCase().includes(filterString));
});
const updateFocus = (index: number) => {
if (index < 0) index = 0;
else if (index >= filteredchildren.length) {
index = filteredchildren.length - 1;
}
focusIndex = index;
};
// setup & register
const focusSearchInput = () => {
setTimeout(() => {
const input = document.getElementsByClassName(className)[0]?.getElementsByTagName('input')[0];
input.focus();
}, 50);
};
function openElement(link: string) {
if (String(link)?.startsWith('/')) {
goto(link);
} else {
window.open(link, '_blank');
}
}
onMount(() => {
manager = new KeyboardShortcutManager();
manager.register({
keys: ['Meta', 'K'],
handler: () => {
if (!window || !('elements' in window)) return;
const elements = window?.elements;
showOverlay('elements', elements);
}
});
manager.register({
keys: ['Meta', 'J'],
handler: () => showOverlay('pages', pages)
});
});
onDestroy(() => manager?.unregisterAll());
</script>
{#if overlayStore.type}
<Dialog close={hideOverlay} title="" description="">
<div class={className}>
<Input label="" bind:value={filterString} placeholder="Search anything..." />
<ul>
{#each filteredchildren as element, index (element)}
<li
class={index === focusIndex ? 'focus' : ''}
on:click={() => 'link' in element && openElement(element.link ?? '/')}
>
<div class="header">
<h3>{element?.name}</h3>
<span class="sub">{element?.link}</span>
</div>
<div class="group">
{#each Object.entries(element) as [key, value] (key)}
<span><b>{key}:</b> {value}</span>
{/each}
</div>
</li>
{/each}
</ul>
<div class="hint">
Press <span class="kbd">Esc</span> to close · <span class="kbd"></span> +
<span class="kbd">K</span> to toggle
</div>
</div>
</Dialog>
{/if}
<style lang="scss">
:global(.dialog .title) {
--padding: 1.2rem;
--background-color: rgba(255, 255, 255, 0.92);
--text-color: black;
}
:global(body.dark .dialog .title) {
--background-color: rgba(25, 25, 34, 0.92);
--text-color: white;
}
:global(.dialog .title .input) {
--padding-w: 1rem;
}
.search-container {
width: 700px;
position: relative;
@media screen and (max-width: 480px) {
width: 100%;
ul {
max-height: calc(50vh);
}
}
ul {
list-style-type: none;
margin: 0;
margin-top: 0.3rem;
padding-left: 0;
overflow-y: scroll;
overflow-y: scroll;
max-height: calc(100vh - var(--offset-top) * 4);
max-height: 75vh;
}
li {
--blur: 18px;
border: var(--border);
border-radius: var(--border-radius);
position: relative;
display: flex;
flex-direction: column;
transition: background 0.15s;
padding: 0.875rem 1rem;
margin: 0.5rem 0;
background-color: var(--card-bg);
cursor: pointer;
user-select: none;
gap: 0.375rem;
&.focus,
&:hover {
&::after {
content: '';
position: absolute;
width: calc(100% - 4px);
height: calc(100% - 2px);
border: 2px solid var(--theme);
top: -1px;
left: -1px;
border-radius: var(--border-radius);
}
}
.header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
h3 {
font-weight: 600;
font-size: 1.1rem;
letter-spacing: 0.2px;
margin: 0;
}
.sub {
color: #999;
font-size: 13px;
font-family: monospace;
}
}
.group {
display: flex;
flex-wrap: wrap;
gap: 0.625rem 1.125rem;
margin-top: 0.25rem;
font-size: 0.8rem;
font-size: 13px;
color: var(--muted);
span {
display: inline-flex;
gap: 4px;
b {
color: var(--key);
font-weight: 400;
}
}
}
}
/* Small footer hint */
.hint {
margin-top: 10px;
text-align: right;
color: var(--muted);
font-size: 13px;
.kbd {
background-color: rgba(0, 0, 0, 0.16);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
}
}
:global(.search-container .label-input) {
position: sticky;
top: 0;
overflow: unset;
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { goto } from '$app/navigation';
import CubeSide from '$lib/icons/cube-side.svelte';
import HardDrive from '$lib/icons/hard-disk.svelte';
import Network from '$lib/icons/network.svelte';
import CPU from '$lib/icons/cpu.svelte';
import Fingerprint from '$lib/icons/fingerprint.svelte';
import ExtractUp from '$lib/icons/extract-up.svelte';
import InsertDown from '$lib/icons/insert-down.svelte';
import Clock from '$lib/icons/clock.svelte';
import Memory from '$lib/icons/floppy-disk.svelte';
import { formatBytes, formatDuration } from '$lib/utils/conversion';
import type { LXC } from '$lib/interfaces/proxmox';
const { lxc }: { lxc: LXC } = $props();
</script>
<div class="card">
<div class="header">
<div class="icon"><CubeSide /></div>
<span class="name">{lxc.name} <span class="subtle">{lxc.vmid}</span></span>
<span class={`status ${lxc.status === 'running' ? 'ok' : 'error'}`}></span>
</div>
<div class="resource">
<div class="title">
<Network />
<span>Status</span>
</div>
<span>{lxc.status}</span>
<div class="title">
<Network />
<span>CPUs</span>
</div>
<span>{lxc.cpus}</span>
<div class="title">
<CPU />
<span>CPU</span>
</div>
<span>{Math.floor(lxc.cpu * 100) / 100}</span>
<div class="title">
<HardDrive />
<span>Max Disk</span>
</div>
<span>{formatBytes(lxc.maxdisk)}</span>
<div class="title">
<Memory />
<span>Memory</span>
</div>
<span>{formatBytes(lxc.mem)}</span>
<div class="title">
<InsertDown />
<span>Net In</span>
</div>
<span>{formatBytes(lxc.netin)}</span>
<div class="title">
<ExtractUp />
<span>Net Out</span>
</div>
<span>{formatBytes(lxc.netout / 8)}</span>
<div class="title">
<Clock />
<span>Uptime</span>
</div>
<span>{formatDuration(lxc.uptime)}</span>
<div class="title">
<Fingerprint />
<span>lxc ID</span>
</div>
<span>{lxc.vmid}</span>
</div>
<div class="footer">
<button on:click={() => goto(`/servers/lxc/${lxc.vmid}`)}>
<span>Pod details</span>
</button>
</div>
</div>
<style lang="scss">
@import '../styles/card.scss';
.card {
flex-grow: 1;
max-width: 550px;
}
</style>

View File

@@ -3,7 +3,7 @@
import { grey400x225 } from '$lib/utils/staticImageSource'; import { grey400x225 } from '$lib/utils/staticImageSource';
import Dialog from './Dialog.svelte'; import Dialog from './Dialog.svelte';
const IMAGE_REFRESH_INTERVAL = 3000; const IMAGE_REFRESH_INTERVAL = 1000;
let { imageUrl }: { imageUrl: string } = $props(); let { imageUrl }: { imageUrl: string } = $props();
let lastUpdated = new Date(); let lastUpdated = new Date();
@@ -21,8 +21,8 @@
imageSource = reader?.result || ''; imageSource = reader?.result || '';
if (imageSource === '') { if (imageSource === '') {
console.log("no image data, returning") console.log('no image data, returning');
return return;
} }
// set imageSource to image element // set imageSource to image element
@@ -78,27 +78,38 @@
}); });
</script> </script>
<div> <div class="liveimage">
{#if !fullscreen} {#if !fullscreen}
<img on:click={() => (fullscreen = !fullscreen)} src={String(imageSource)} id="live-image" /> <img on:click={() => (fullscreen = !fullscreen)} src={String(imageSource)} id="live-image" />
{:else} {:else}
<Dialog title="Live stream of printer" on:close={() => (fullscreen = false)}> <div class="fullscreen-container">
<img style="width: 100%;" src={String(imageSource)} id="live-image" /> <Dialog title="Live stream of printer" close={() => (fullscreen = false)}>
<span>Last update {timestamp}s ago</span> <img src={String(imageSource)} id="live-image" />
</Dialog> <span>Last update {timestamp}s ago</span>
</Dialog>
<img src={String(grey400x225)} /> <img src={String(grey400x225)} />
</div>
{/if} {/if}
<span>Last update {timestamp}s ago</span> <span>Last update {timestamp}s ago</span>
</div> </div>
<style lang="scss"> <style lang="scss">
img { .liveimage img {
width: 400px; width: 100%;
max-width: 400px;
border-radius: 0.5rem; border-radius: 0.5rem;
} }
span { span {
display: block; display: block;
} }
:global(.fullscreen-container .dialog img) {
max-width: unset;
}
:global(.fullscreen-container #dialog-title) {
max-width: 98vw;
}
</style> </style>

View File

@@ -69,133 +69,5 @@
</div> </div>
<style lang="scss"> <style lang="scss">
.card { @import "../styles/card.scss";
flex-grow: 1;
max-width: 550px;
background: #fbf6f4;
box-shadow: var(
--str-shadow-s,
0px 0px 2px #22242714,
0px 1px 4px #2224271f,
0px 4px 8px #22242729
);
pointer-events: all;
cursor: auto;
}
.header {
display: flex;
padding: 0.75rem;
background-color: white;
align-items: center;
font-size: 16px;
.icon {
height: 24px;
width: 24px;
margin-right: 0.75rem;
}
.status {
height: 1rem;
width: 1rem;
border-radius: 50%;
margin-left: auto;
position: relative;
&.ok {
background-color: var(--positive);
}
&.warning {
background-color: var(--warning);
}
&.error {
background-color: var(--negative);
}
}
}
.footer {
padding: 0.5rem;
background-color: white;
}
.resource {
display: grid;
grid-template-columns: auto auto;
padding: 0.5rem;
background-color: var(--bg);
row-gap: 6px;
column-gap: 20px;
> div,
span {
display: flex;
padding: 0 0.5rem;
}
}
:global(.resource .title svg) {
height: 1rem;
width: 1rem;
}
.footer {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: auto;
background: white;
padding: 0.5rem;
border-bottom-left-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
button {
border: none;
position: relative;
background: transparent;
height: unset;
border-radius: 0.5rem;
display: inline-block;
text-decoration: none;
padding: 0 0.5rem;
flex: 1;
span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 1.5rem;
padding: 0 0.5rem;
margin-left: -0.5rem;
border: 1px solid #eaddd5;
border-radius: inherit;
white-space: nowrap;
cursor: pointer;
font-weight: 700;
}
&::after {
content: '';
position: absolute;
right: 0;
top: 0;
border-radius: 0.5rem;
width: 100%;
height: 100%;
transition: transform 0.1s ease;
will-change: box-shadow 0.25s;
pointer-events: none;
}
}
}
.positive {
color: #077c35;
}
</style> </style>

View File

@@ -105,6 +105,8 @@
</div> </div>
<style lang="scss"> <style lang="scss">
@import "../styles/card.scss";
.card-container { .card-container {
background-color: #cab2aa40; background-color: #cab2aa40;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -122,99 +124,4 @@
gap: 2rem; gap: 2rem;
} }
} }
.card {
flex-grow: 1;
max-width: 550px;
background: #fbf6f4;
box-shadow: var(
--str-shadow-s,
0px 0px 2px #22242714,
0px 1px 4px #2224271f,
0px 4px 8px #22242729
);
pointer-events: all;
cursor: auto;
&.not-running {
border: 2px dashed var(--theme);
opacity: 0.6;
}
}
.header {
display: flex;
padding: 0.75rem;
background-color: white;
align-items: center;
font-size: 16px;
.icon {
height: 24px;
width: 24px;
margin-right: 0.75rem;
}
.status {
height: 1rem;
width: 1rem;
border-radius: 50%;
margin-left: auto;
position: relative;
&.ok {
background-color: var(--positive);
}
&.warning {
background-color: var(--warning);
}
&.error {
background-color: var(--negative);
}
}
}
.footer {
padding: 0.5rem;
background-color: white;
}
.resource {
display: grid;
grid-template-columns: auto auto;
padding: 0.5rem;
background-color: var(--bg);
row-gap: 6px;
column-gap: 20px;
> div,
span {
display: flex;
padding: 0 0.5rem;
}
}
:global(.resource .title svg) {
height: 1rem;
width: 1rem;
}
.footer {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: auto;
background: white;
padding: 0.5rem;
border-bottom-left-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
}
.positive {
color: #077c35;
}
</style> </style>

View File

@@ -4,13 +4,17 @@
</script> </script>
<article class="main-container"> <article class="main-container">
<div class="header"> {#if title || description}
<div class="title"> <div class="header">
<h2>{title}</h2> <div class="title">
<slot name="top-left" /> <h2>{title}</h2>
<slot name="top-left" />
</div>
{#if description && description?.length > 0}
<label>{description}</label>
{/if}
</div> </div>
<label>{description}</label> {/if}
</div>
<slot></slot> <slot></slot>
</article> </article>

View File

@@ -24,7 +24,7 @@
link: `https://${node.ip}:8006/#v1:0:=node%2F${node.name}:4:=jsconsole::::::` link: `https://${node.ip}:8006/#v1:0:=node%2F${node.name}:4:=jsconsole::::::`
}, },
{ name: 'Graphs', link: `https://${node.ip}:8006/#v1:0:=node%2F${node.name}:4:5::::::` }, { name: 'Graphs', link: `https://${node.ip}:8006/#v1:0:=node%2F${node.name}:4:5::::::` },
{ name: 'Web', link: `https://${node.ip}:8006/` } { name: 'Details', link: `/servers/node/${node.name}` }
]; ];
let { cpuinfo, memory, uptime, loadavg } = node.info; let { cpuinfo, memory, uptime, loadavg } = node.info;
@@ -116,7 +116,7 @@
<div class="footer"> <div class="footer">
{#each buttons as btn (btn)} {#each buttons as btn (btn)}
<a href={btn.link} target="_blank" rel="noopener noreferrer"> <a href={btn.link} target={btn.link[0] === '/' ? '' : '_blank'} rel="noopener noreferrer">
<button> <button>
<span>{btn.name}</span> <span>{btn.name}</span>
</button> </button>
@@ -126,163 +126,5 @@
</div> </div>
<style lang="scss"> <style lang="scss">
@keyframes pulse-live { @import '../styles/card.scss';
0% {
box-shadow: 0 0 0 0 rgba(0, 212, 57, 0.7);
box-shadow: 0 0 0 0 rgba(0, 212, 57, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(0, 212, 57, 0);
box-shadow: 0 0 0 10px rgba(0, 212, 57, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 212, 57, 0);
box-shadow: 0 0 0 0 rgba(0, 212, 57, 0);
}
}
@mixin pulse-dot {
&::after {
content: '';
top: 50%;
margin-left: 0.4rem;
position: absolute;
display: block;
border-radius: 50%;
background-color: var(--color);
border-radius: 50%;
transform: translate(-50%, -50%);
animation: pulse-live 2s infinite;
height: 16px;
width: 16px;
}
}
.card {
background: #fbf6f4;
box-shadow: var(
--str-shadow-s,
0px 0px 2px #22242714,
0px 1px 4px #2224271f,
0px 4px 8px #22242729
);
pointer-events: all;
cursor: auto;
}
.header {
display: flex;
padding: 0.75rem;
background-color: white;
align-items: center;
font-size: 16px;
.icon {
height: 24px;
width: 24px;
margin-right: 0.75rem;
}
.status {
height: 1rem;
width: 1rem;
border-radius: 50%;
margin-left: auto;
position: relative;
&.ok {
--color: var(--positive);
@include pulse-dot;
}
&.warning {
background-color: var(--warning);
}
&.error {
background-color: var(--negative);
}
}
}
.footer {
padding: 0.5rem;
background-color: white;
}
.resource {
display: grid;
grid-template-columns: auto auto;
padding: 0.5rem;
background-color: var(--bg);
row-gap: 6px;
max-width: 330px;
> div,
span {
display: flex;
padding: 0 0.5rem;
}
}
:global(.resource .title svg) {
height: 1rem;
width: 1rem;
}
.footer {
display: flex;
align-items: center;
justify-content: space-evenly;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: auto;
background: white;
padding: 0.5rem;
border-bottom-left-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
button {
border: none;
position: relative;
background: transparent;
height: unset;
border-radius: 0.5rem;
display: inline-block;
text-decoration: none;
padding: 0 0.5rem;
flex: 1;
span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 1.5rem;
padding: 0 0.5rem;
margin-left: -0.5rem;
border: 1px solid #eaddd5;
border-radius: inherit;
white-space: nowrap;
cursor: pointer;
font-weight: 700;
}
&::after {
content: '';
position: absolute;
right: 0;
top: 0;
border-radius: 0.5rem;
width: 100%;
height: 100%;
transition: transform 0.1s ease;
will-change: box-shadow 0.25s;
pointer-events: none;
}
}
}
.positive {
color: #077c35;
}
</style> </style>

View File

@@ -173,6 +173,19 @@
} }
} }
:global(body.dark .nav-wrapper.open) {
@media (prefers-color-scheme: dark) {
background-color: var(--bg);
nav a {
&:hover,
&.highlight {
color: white !important;
}
}
}
}
nav { nav {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

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

View File

@@ -1,13 +1,7 @@
<script lang="ts"> <script lang="ts">
import External from '$lib/icons/external.svelte'; import External from '$lib/icons/external.svelte';
import type { Site } from '$lib/interfaces/site.ts';
interface Site {
title: string;
image: string;
link: string;
background?: string;
color?: string;
}
let { title, image, background, color, link }: Site = $props(); let { title, image, background, color, link }: Site = $props();
@@ -60,7 +54,7 @@
h2, h2,
.link, .link,
.title { .title {
transition: all 0.2s ease-in-out; transition: all 0.18s ease-in-out;
} }
.title { .title {
@@ -90,7 +84,8 @@
.image { .image {
height: 8rem; height: 8rem;
width: 100%; width: 100%;
margin: 1.2rem 0; width: 8rem;
margin: 1.2rem auto;
background-size: contain; background-size: contain;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center; background-position: center;
@@ -105,6 +100,7 @@
left: calc(100% - 2rem); left: calc(100% - 2rem);
top: 0; top: 0;
opacity: 0; opacity: 0;
fill: var(--color);
} }
@media screen and (max-width: 750px) { @media screen and (max-width: 750px) {
@@ -130,7 +126,6 @@
opacity: 1; opacity: 1;
left: calc(100% - 1rem); left: calc(100% - 1rem);
top: 2px; top: 2px;
fill: var(--color);
} }
} }
} }

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { goto } from '$app/navigation'
import Desktop from '$lib/icons/desktop.svelte';
import HardDrive from '$lib/icons/hard-disk.svelte';
import Network from '$lib/icons/network.svelte';
import CPU from '$lib/icons/cpu.svelte';
import Fingerprint from '$lib/icons/fingerprint.svelte';
import ExtractUp from '$lib/icons/extract-up.svelte';
import InsertDown from '$lib/icons/insert-down.svelte';
import Clock from '$lib/icons/clock.svelte';
import Memory from '$lib/icons/floppy-disk.svelte';
import { formatBytes, formatDuration } from '$lib/utils/conversion';
import type { VM } from '$lib/interfaces/proxmox';
const { vm }: { vm: VM } = $props();
</script>
<div class="card">
<div class="header">
<div class="icon"><Desktop /></div>
<span class="name">{vm.name} <span class="subtle">{vm.vmid}</span></span>
<span class={`status ${vm.status === 'running' ? 'ok' : 'error'}`}></span>
</div>
<div class="resource">
<div class="title">
<Network />
<span>Status</span>
</div>
<span>{vm.status}</span>
<div class="title">
<Network />
<span>CPUs</span>
</div>
<span>{vm.cpus}</span>
<div class="title">
<CPU />
<span>CPU</span>
</div>
<span>{Math.floor(vm.cpu * 100) / 100}</span>
<div class="title">
<HardDrive />
<span>Max Disk</span>
</div>
<span>{formatBytes(vm.maxdisk)}</span>
<div class="title">
<Memory />
<span>Memory</span>
</div>
<span>{formatBytes(vm.mem)}</span>
<div class="title">
<InsertDown />
<span>Net In</span>
</div>
<span>{formatBytes(vm.netin)}</span>
<div class="title">
<ExtractUp />
<span>Net Out</span>
</div>
<span>{formatBytes(vm.netout / 8)}</span>
<div class="title">
<Clock />
<span>Uptime</span>
</div>
<span>{formatDuration(vm.uptime)}</span>
<div class="title">
<Fingerprint />
<span>VM ID</span>
</div>
<span>{vm.vmid}</span>
</div>
<div class="footer">
<button on:click={() => goto(`/servers/vm/${vm.name}`)}>
<span>VM details</span>
</button>
</div>
</div>
<style lang="scss">
@import '../styles/card.scss';
.card {
flex-grow: 1;
max-width: 550px;
}
</style>

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import Input from '$lib/components/Input.svelte';
import TextSize from '$lib/icons/text-size.svelte';
import Quill from '$lib/icons/quill.svelte';
import Tag from '$lib/icons/tag.svelte';
import Dropdown from '../Dropdown.svelte';
const { close }: { close: () => void } = $props();
const dnsTypes = ['A', 'AAAA', 'CNAME', 'NS', 'SOA', 'MX', 'TXT', 'PTR'];
</script>
<form method="POST">
<div class="wrapper">
<Input label="Name" icon={TextSize} placeholder="Website name" required />
<Dropdown
placeholder="Record type"
label="Type"
required={true}
icon={Tag}
options={dnsTypes}
/>
<Input label="Content" icon={Quill} placeholder="IPv4 address" required />
</div>
<footer>
<button on:click={close} aria-disabled="false" type="button" tabindex="0"
><span tabindex="-1">Cancel</span></button
>
<button class="affirmative" type="submit" tabindex="-1">
<span tabindex="-1">Add record</span>
</button>
</footer>
</form>
<style lang="scss">
form {
.wrapper {
display: flex;
flex-direction: column;
}
footer {
padding: 0.75rem 1.5rem;
max-width: 100%;
height: 2.5rem;
width: auto;
display: flex;
justify-content: flex-end;
flex: 0 0 auto;
gap: 1rem;
flex-wrap: wrap;
button {
flex: unset;
span {
font-size: 0.8rem;
}
}
}
}
:global(form .wrapper div) {
margin-bottom: 0.5rem;
}
</style>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Input from '$lib/components/Input.svelte';
import ColorInput from '$lib/components/ColorInput.svelte';
import Id from '$lib/icons/id.svelte';
import TextColor from '$lib/icons/text-color.svelte';
import TextSize from '$lib/icons/text-size.svelte';
import Window from '$lib/icons/window.svelte';
import Quill from '$lib/icons/quill.svelte';
import Tag from '$lib/icons/tag.svelte';
import Link from '$lib/icons/link.svelte';
import PaintRoller from '$lib/icons/paint-roller.svelte';
import Bucket from '$lib/icons/bucket.svelte';
import Picture from '$lib/icons/picture.svelte';
const dispatch = createEventDispatcher();
const close = () => dispatch('close');
</script>
<form method="POST">
<div class="wrapper">
<Input label="Name" icon={TextSize} placeholder="Website name" required />
<Input label="Link" icon={Link} placeholder="https://site.tld" required />
<Input label="Image" icon={Picture} placeholder="/images/site.png" required />
<ColorInput label="Color" icon={PaintRoller} placeholder="#21ADF6" required />
<ColorInput label="Background" icon={Bucket} />
</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

@@ -15,22 +15,6 @@
</div> </div>
<style lang="scss"> <style lang="scss">
button {
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
margin: 0;
letter-spacing: 0.2px;
&.selected {
opacity: 1;
letter-spacing: unset;
border-bottom-color: var(--color) !important;
font-weight: 600 !important;
}
}
.tab { .tab {
&:not(&:first-of-type) { &:not(&:first-of-type) {
margin-left: 0.75rem; margin-left: 0.75rem;
@@ -40,10 +24,21 @@
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; background: none;
opacity: 0.7; opacity: 0.6;
margin: 0;
padding-bottom: 0.3rem; padding-bottom: 0.3rem;
transition: 0.3s ease-in-out all; border-radius: 0;
border: none;
border-bottom: 2px solid transparent;
letter-spacing: 0.2px;
&.selected {
opacity: 1;
letter-spacing: unset;
border-bottom-color: var(--color) !important;
font-weight: 600 !important;
}
} }
} }
</style> </style>

View File

@@ -0,0 +1,282 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { formatBytes, formatDuration } from '$lib/utils/conversion';
import Cpu from '$lib/icons/cpu.svelte';
import Section from '../Section.svelte';
import type { Node, VM } from '$lib/interfaces/proxmox';
import FloppyDisk from '$lib/icons/floppy-disk.svelte';
import HardDisk from '$lib/icons/hard-disk.svelte';
import Clock from '$lib/icons/clock.svelte';
import InsertDown from '$lib/icons/insert-down.svelte';
import ExtractUp from '$lib/icons/extract-up.svelte';
import Table from '../Table.svelte';
import Power from '$lib/icons/power.svelte';
const { vm, node }: { vm: VM, node: Node } = $props();
console.log(node)
const drives = Object.entries(vm.config)
.filter(([key, _]) => {
return key.startsWith('scsi');
})
?.map(([key, value]) => {
const { disk, backup, discard, iothread, size } =
/(?<disk>[\w\:\-]+),(backup=(?<backup>[\w|\d]+))?[,]?(discard=(?<discard>[\w|\d]+))?[,]?(iothread=(?<iothread>[\w|\d]+))?[,]?(size=(?<size>[\w|\d]+)\w)?[,]?/.exec(
value
)?.groups || {};
if (!disk) return;
return {
device: key,
mount: disk,
backup: backup === '1' ? 'enabled' : 'disabled',
size: Number(size || 1) * 1024
};
})
.filter((d) => d)
.sort((a, b) =>
Number(a?.device.match(/\d+/)[0]) > Number(b?.device.match(/\d+/)[0]) ? 1 : -1
);
const pcieDevices = Object.entries(vm.config)
.filter(([key, _]) => {
return key.startsWith('hostpci');
})
?.map(([key, value]) => {
return {
device: key,
name: value
};
});
</script>
<div>
<Section title="Resources" description="">
<div class="section-row">
<div class="section-element">
<label>ID</label>
<span>{vm.vmid}</span>
</div>
<div class="section-element">
<label>Memory usage</label>
<span
><span class="icon"><FloppyDisk /></span>{formatBytes(vm.mem).match(/\d+(\.\d+)?/)?.[0]} /
{formatBytes(vm.maxmem)}</span
>
</div>
<div class="section-element">
<label>CPUs</label>
<span>
<span class="icon"><Cpu /></span>
{vm.cpus} cores ({vm.config.sockets})
</span>
</div>
<div class="section-element">
<label>Disk</label>
<span>
<span class="icon"><HardDisk /></span>
{formatBytes(vm.maxdisk)}
</span>
</div>
<div class="section-element">
<label>Uptime</label>
<span>
<span class="icon"><Clock /></span>
{formatDuration(vm.uptime)}
</span>
</div>
</div>
</Section>
<Section title="Status" description="">
<div class="section-row">
<div class="section-element">
<label>State</label>
<span>
<span class="icon"><Power /></span>
{vm.status}
</span>
</div>
<div class="section-element">
<label>CPU load</label>
<span>{Math.floor(vm.cpu * 10000) / 100} %</span>
</div>
<div class="section-element">
<label>Memory usage</label>
<span>{Math.floor((vm.mem / vm.maxmem) * 1000) / 10} %</span>
</div>
<div class="section-element">
<label>Netout</label>
<span>
<span class="icon"><ExtractUp /></span>
{formatBytes(vm.netout)}
</span>
</div>
<div class="section-element">
<label>Netin</label>
<span>
<span class="icon"><InsertDown /></span>
{formatBytes(vm.netin)}
</span>
</div>
<div class="section-element">
<label>Disk read</label>
<span>
<span class="icon"><InsertDown /></span>
{vm.diskread}
</span>
</div>
<div class="section-element">
<label>Disk write</label>
<span>
<span class="icon"><InsertDown /></span>
{vm.diskwrite}
</span>
</div>
</div>
</Section>
<Section title="Operating System" description="">
<div class="section-row">
<div class="section-element">
<label>Name</label>
<span>{vm.os?.name}</span>
</div>
<div class="section-element">
<label>Name</label>
<span>{vm.os?.['pretty-name']}</span>
</div>
<div class="section-element">
<label>Version</label>
<span>{vm.os?.version}</span>
</div>
<div class="section-element">
<label>Kernel</label>
<span>{vm.os?.['kernel-release']}</span>
</div>
</div>
</Section>
<Section title="Filesystem" description="">
{#if drives?.length > 0}
<Table title="FS Devices" columns={['name', 'bus', 'mountpoint', 'type', 'space', 'size']}>
<tbody slot="tbody">
{#each vm.fs as device (device)}
<tr>
<td>{device.name}</td>
<td>
{#each device['disk'] as device (device)}
<span>{device?.['bus-type']}{device?.target}</span>
{/each}
</td>
<td>{device.mountpoint}</td>
<td>{device.type}</td>
<td>{Math.floor((device['used-bytes'] / device['total-bytes']) * 10000) / 100} %</td>
<td>{formatBytes(device['total-bytes'])}</td>
</tr>
{/each}
</tbody>
</Table>
{/if}
</Section>
<Section title="Devices" description="">
{#if vm.network?.length > 0}
<Table title="Network interfaces" columns={['name', 'hardward address', 'ip addresses']}>
<tbody slot="tbody">
{#each vm.network.sort((a,b) => a < b ? 1 : -1) as net_interface (net_interface.name)}
<tr>
<td>{net_interface.name}</td>
<td>{net_interface['hardware-address']}</td>
<td class="ip-addresses">
{#each net_interface['ip-addresses'] as ip (ip['ip-address'])}
<span>{ip['ip-address']}/{ip.prefix}</span>
{/each}
</td>
</tr>
{/each}
</tbody>
</Table>
{/if}
{#if drives?.length > 0}
<Table title="Hard drives" columns={['device', 'mount', 'backup', 'size']}>
<div slot="actions">
<p>Total drives: {drives.length}</p>
<p>Total capacity: {formatBytes(drives.reduce((acc, obj) => acc + obj.size, 0))}</p>
</div>
<tbody slot="tbody">
{#each drives as drive (drive)}
<tr>
<td>{drive.device}</td>
<td>{drive.mount}</td>
<td>{drive.backup}</td>
<td>{formatBytes(drive.size)}</td>
</tr>
{/each}
</tbody>
</Table>
{/if}
{#if pcieDevices?.length > 0}
<Table title="PCIe devices" columns={Object.keys(pcieDevices[0])}>
<tbody slot="tbody">
{#each pcieDevices as pcie (pcie)}
<tr>
<td>{pcie.device}</td>
<td>{pcie.name}</td>
</tr>
{/each}
</tbody>
</Table>
{/if}
</Section>
</div>
<style lang="scss">
:global(article.main-container:not(:first-child)) {
margin-top: 1rem;
}
.section-element {
.icon {
display: inline-block;
--size: 1.3rem;
height: var(--size);
width: var(--size);
padding-right: 0.5rem;
&.spin {
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
animation: rotate 6s linear infinite;
transform-origin: calc((var(--size) / 2) - 2px) calc(var(--size) / 2);
}
}
}
tbody .ip-addresses {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,5 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
<path d="M671.9 418c0.6-9.2-2.8-18.1-9.3-24.6l-143.1-143.2c17.1-27.9 30.8-55.3 40.5-81.4 23.9-64.7 20.9-116.6-8.5-146.3-14.9-14.9-33.2-22.5-54.4-22.5v0c-31.1 0-68.8 16.6-115.2 50.8-10.9 8.1-22.4 17.2-34.5 27.3l-4.8-4.8c-6.5-6.5-15.5-9.9-24.6-9.3-9.2 0.6-17.6 5.1-23.3 12.3l-280.7 361.8c-19.7 25.4-17.4 61.7 5.3 84.5l194.1 194.1c12.4 12.4 28.8 18.7 45.3 18.7 13.8 0 27.6-4.4 39.2-13.4l361.7-280.7c7.3-5.7 11.8-14.1 12.3-23.3zM497.1 32v0c12.7 0 22.8 4.2 31.7 13.1 19.8 20 20.3 61 1.2 112.6-8.2 22.2-19.6 45.5-33.7 69.2l-126.3-126.1c47.6-39.6 93.5-68.8 127.1-68.8zM258.7 671.4l-194.1-194.1 30.6-39.5 202.9 202.9-39.4 30.7zM323.6 621l-208.6-208.6 208-268.1 136.8 136.7c-11.3 15.1-23.6 30.1-36.6 44.9-6.9-3.8-14.8-6-23.1-6-26.5 0-48 21.5-48 48s21.5 48 48 48c26.5 0 48-21.5 48-48 0-6.3-1.2-12.4-3.5-17.9 13.5-15.2 26.3-30.7 38.1-46.2l109 109.2-268.1 208zM416 368c0 8.8-7.2 16-16 16s-16-7.2-16-16 7.2-16 16-16 16 7.2 16 16z"></path>
<path d="M730.3 584.5c-12.3-21.9-23.9-42.6-26.4-58.9-1.2-7.8-7.9-13.6-15.8-13.6s-14.6 5.8-15.8 13.6c-2.5 16.4-14.1 37-26.4 58.9-17.9 31.5-37.9 67.2-37.9 103.4 0 44.1 35.9 80 80 80s80-35.9 80-80c0-36.2-20-71.9-37.7-103.4zM688 735.9c-26.5 0-48-21.5-48-48 0-27.8 17.1-58.3 33.6-87.7 5-9 10-17.7 14.4-26.3 4.4 8.6 9.3 17.4 14.4 26.3 16.5 29.4 33.6 59.9 33.6 87.7 0 26.5-21.5 48-48 48z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,5 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
<path d="M480 160c-35.3 0-64 28.7-64 64s28.7 64 64 64 64-28.7 64-64-28.7-64-64-64zM480 256c-17.6 0-32-14.4-32-32s14.4-32 32-32 32 14.4 32 32-14.4 32-32 32z"></path>
<path d="M764.8 390.4l-103.6-138.1-19.1-64.8c-0.5-1.7-1.2-3.2-2.1-4.5v-55c0-35.3-28.7-64-64-64h-512c-35.3 0-64 28.7-64 64v192c0 35.3 28.7 64 64 64h11.5l53.1 180.5c2.1 7 8.4 11.5 15.3 11.5 1.5 0 3-0.2 4.5-0.7l94.2-27.7 112.5 150c3.1 4.2 7.9 6.4 12.8 6.4 3.3 0 6.7-1 9.6-3.2l384-288c7.2-5.3 8.6-15.3 3.3-22.4zM64 128c0 0 0 0 0 0h512v192h-512v-192zM108.9 384h467.1c35.3 0 64-28.7 64-64v-26.5l28.1 95.6-513.3 151-45.9-156.1zM371.2 665.6l-95.7-127.6 417-122.7c4.1-1.2 7.5-4 9.5-7.7s2.5-8.1 1.3-12.2l-16.3-55.3 42.6 56.7-358.4 268.8z"></path>
</svg>

After

Width:  |  Height:  |  Size: 869 B

View File

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

Before

Width:  |  Height:  |  Size: 816 B

After

Width:  |  Height:  |  Size: 818 B

View File

@@ -1,12 +1,10 @@
<!-- Generated by IcoMoon.io -->
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="768" width="100%"
height="768" height="100%"
viewBox="0 0 768 768" viewBox="0 0 768 768"
> >
<g id="icomoon-ignore"> </g>
<path <path
d="M701.2 186.6c0 0 0 0 0 0l-288-147.6c-18.3-9.3-40.1-9.3-58.4 0l-288 147.6c-21.4 11-34.8 32.9-34.8 57v280.9c0 24.1 13.3 45.9 34.8 57l288 147.6c9.1 4.7 19.1 7 29.1 7s20.1-2.3 29.2-7l288-147.6c21.4-11 34.8-32.9 34.8-57v-280.9c0.1-24.1-13.2-45.9-34.7-57zM384 96l249.8 128-249.8 128-249.8-128 249.8-128zM96 276.4l256 131.2v248.2l-256-131.3v-248.1zM416 655.7v-248.1l256-131.2v248.2l-256 131.1z" d="M701.2 186.6c0 0 0 0 0 0l-288-147.6c-18.3-9.3-40.1-9.3-58.4 0l-288 147.6c-21.4 11-34.8 32.9-34.8 57v280.9c0 24.1 13.3 45.9 34.8 57l288 147.6c9.1 4.7 19.1 7 29.1 7s20.1-2.3 29.2-7l288-147.6c21.4-11 34.8-32.9 34.8-57v-280.9c0.1-24.1-13.2-45.9-34.7-57zM384 96l249.8 128-249.8 128-249.8-128 249.8-128zM96 276.4l256 131.2v248.2l-256-131.3v-248.1zM416 655.7v-248.1l256-131.2v248.2l-256 131.1z"
></path> ></path>

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 805 B

View File

@@ -0,0 +1,4 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
<path d="M704 64h-640c-35.3 0-64 28.7-64 64v352c0 35.3 28.7 64 64 64h288v64c0 15.1-12.4 30.6-34 42.6-24.9 13.8-58.3 21.4-94 21.4v32h320v-32c-35.8 0-69.2-7.6-94-21.4-21.6-12-34-27.5-34-42.6v-64h288c35.3 0 64-28.7 64-64v-352c0-35.3-28.7-64-64-64zM423.8 672h-79.6c24.9-16.9 39.8-39.2 39.8-64 0 24.8 14.9 47.1 39.8 64zM64 128h640v288h-640v-288c-0.1 0 0 0 0 0zM64 480v-32h640v32h-640z"></path>
</svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@@ -0,0 +1,5 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
<path d="M656 352h-208v32h192v320h-512v-320h192v-32h-208c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h544c8.8 0 16-7.2 16-16v-352c0-8.8-7.2-16-16-16z"></path>
<path d="M352 141.3v402.7h64v-402.7l81.4 81.4 45.3-45.3-136-136c-12.5-12.5-32.8-12.5-45.3 0l-136 136 45.3 45.3 81.3-81.4z"></path>
</svg>

After

Width:  |  Height:  |  Size: 455 B

View File

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

Before

Width:  |  Height:  |  Size: 896 B

After

Width:  |  Height:  |  Size: 879 B

View File

@@ -1,12 +1,10 @@
<!-- Generated by IcoMoon.io -->
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="768" width="100%"
height="768" height="100%"
viewBox="0 0 768 768" viewBox="0 0 768 768"
> >
<g id="icomoon-ignore"> </g>
<path <path
d="M672 32h-576c-35.3 0-64 28.7-64 64v498.7c0 17.1 6.7 33.1 18.8 45.2l77.3 77.3c12.1 12.1 28.2 18.8 45.2 18.8h498.7c35.3 0 64-28.7 64-64v-576c0-35.3-28.7-64-64-64zM192 64h384v256c0 17.6-14.4 32-32 32h-320c-17.6 0-32-14.4-32-32v-256zM544 704h-288v-192h288v192zM672 672h-96v-176c0-8.8-7.2-16-16-16h-320c-8.8 0-16 7.2-16 16v176h-50.7l-77.3-77.3v-498.7h64v224c0 35.3 28.7 64 64 64h320c35.3 0 64-28.7 64-64v-224h64v576z" d="M672 32h-576c-35.3 0-64 28.7-64 64v498.7c0 17.1 6.7 33.1 18.8 45.2l77.3 77.3c12.1 12.1 28.2 18.8 45.2 18.8h498.7c35.3 0 64-28.7 64-64v-576c0-35.3-28.7-64-64-64zM192 64h384v256c0 17.6-14.4 32-32 32h-320c-17.6 0-32-14.4-32-32v-256zM544 704h-288v-192h288v192zM672 672h-96v-176c0-8.8-7.2-16-16-16h-320c-8.8 0-16 7.2-16 16v176h-50.7l-77.3-77.3v-498.7h64v224c0 35.3 28.7 64 64 64h320c35.3 0 64-28.7 64-64v-224h64v576z"
></path> ></path>

Before

Width:  |  Height:  |  Size: 760 B

After

Width:  |  Height:  |  Size: 699 B

View File

@@ -1,12 +1,10 @@
<!-- Generated by IcoMoon.io -->
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="768" width="100%"
height="768" height="100%"
viewBox="0 0 768 768" viewBox="0 0 768 768"
> >
<g id="icomoon-ignore"> </g>
<path <path
d="M640 0h-512c-35.3 0-64 28.7-64 64v640c0 35.3 28.7 64 64 64h512c35.3 0 64-28.7 64-64v-640c0-35.3-28.7-64-64-64zM640 704h-512v-640h512v640c0 0 0 0 0 0z" d="M640 0h-512c-35.3 0-64 28.7-64 64v640c0 35.3 28.7 64 64 64h512c35.3 0 64-28.7 64-64v-640c0-35.3-28.7-64-64-64zM640 704h-512v-640h512v640c0 0 0 0 0 0z"
></path> ></path>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

9
src/lib/icons/id.svelte Normal file
View File

@@ -0,0 +1,9 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" height="100%" width="100%">
<path d="M704 128h-640c-35.3 0-64 28.7-64 64v384c0 35.3 28.7 64 64 64h640c35.3 0 64-28.7 64-64v-384c0-35.3-28.7-64-64-64zM704 576h-640v-384h640v384c0.1 0 0 0 0 0z"></path>
<path d="M416 256h256v32h-256v-32z"></path>
<path d="M416 320h192v32h-192v-32z"></path>
<path d="M416 384h256v32h-256v-32z"></path>
<path d="M416 448h256v32h-256v-32z"></path>
<path d="M298.3 402.5c-4.7-2.8-9.6-5.2-14.6-7.4 22.1-17.6 36.3-44.7 36.3-75.1 0-52.9-43.1-96-96-96s-96 43.1-96 96c0 30.4 14.2 57.5 36.3 75.1-5 2.1-9.9 4.6-14.6 7.4-20.2 11.9-37 29.3-51.4 53.3-4.5 7.4-2.2 17.1 5.1 21.8 36.6 23.1 76.1 34.4 120.6 34.4s84-11.3 120.6-34.5c7.3-4.7 9.6-14.3 5.1-21.8-14.4-23.9-31.2-41.3-51.4-53.2zM160 320c0-35.3 28.7-64 64-64s64 28.7 64 64-28.7 64-64 64-64-28.7-64-64zM224 480c-32.6 0-61.9-7-89.2-21.3 22.1-29.4 50.2-42.7 89.2-42.7s67.1 13.3 89.2 42.7c-27.3 14.3-56.6 21.3-89.2 21.3z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,5 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
<path d="M656 320h-208v32h192v320h-512v-320h192v-32h-208c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h544c8.8 0 16-7.2 16-16v-352c0-8.8-7.2-16-16-16z"></path>
<path d="M270.6 417.4l-45.3 45.3 136 136c6.2 6.2 14.4 9.4 22.6 9.4s16.4-3.1 22.6-9.4l136-136-45.3-45.3-81.2 81.3v-434.7h-64v434.7l-81.4-81.3z"></path>
</svg>

After

Width:  |  Height:  |  Size: 475 B

View File

@@ -0,0 +1,5 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
<path d="M656 64h-544c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h208v-32h-192v-320h512v320h-192v32h208c8.8 0 16-7.2 16-16v-352c0-8.8-7.2-16-16-16z"></path>
<path d="M497.4 350.6l45.3-45.3-136-136c-12.5-12.5-32.8-12.5-45.3 0l-136 136 45.3 45.3 81.4-81.4v434.8h64v-434.7l81.3 81.3z"></path>
</svg>

After

Width:  |  Height:  |  Size: 456 B

View File

@@ -0,0 +1,5 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
<path d="M672 96h-32v-32c0-35.3-28.7-64-64-64h-416c-35.3 0-64 28.7-64 64v96c0 35.3 28.7 64 64 64h416c35.3 0 64-28.7 64-64v-32h32c17.6 0 32 14.4 32 32v96c0 17.6-14.4 32-32 32h-256c-35.3 0-64 28.7-64 64v1.6c-36.5 7.4-64 39.8-64 78.4v256c0 44.1 35.9 80 80 80s80-35.9 80-80v-256c0-38.6-27.5-71-64-78.4v-1.6c0-17.6 14.4-32 32-32h256c35.3 0 64-28.7 64-64v-96c0-35.3-28.7-64-64-64zM128 160v-96c0-17.6 14.4-32 32-32h416c17.6 0 32 14.4 32 32v32c0 17.6-14.4 32-32 32-14.2 0-21-7.9-32.8-23.1-13.4-17.3-31.7-40.9-71.2-40.9-33.3 0-49.3 20.6-62.2 37.2-13.3 17.1-21.8 26.8-41.8 26.8-26.8 0-45.1-9-62.8-17.6-21.2-10.4-43.1-21.1-71.4-9.1-18.1 7.6-29.4 16.4-35.7 27.5-6.1 10.7-6.1 21.4-6.1 30v1.2c0 17.6-14.4 32-32 32s-32-14.4-32-32zM224 160v-1.3c0-6.9 0.1-11 1.9-14.2 2.5-4.5 9.6-9.2 20.3-13.8 14.3-6 24.7-1.6 44.9 8.4 19 9.3 42.6 20.9 76.9 20.9 36.7 0 53.5-21.7 67.1-39.2 12.7-16.3 20.1-24.8 36.9-24.8 23.8 0 33.6 12.6 45.9 28.5 12.3 15.8 27.6 35.5 58.1 35.5h-352zM384 688c0 8.8-7.2 16-16 16s-16-7.2-16-16v-256c0-8.8 7.2-16 16-16s16 7.2 16 16v256z"></path>
<path d="M159.9 301.8c-1.1-7.9-7.9-13.8-15.9-13.8s-14.8 5.9-15.9 13.8c-1.2 8.4-6.7 19.8-12.7 31.9-9.1 18.6-19.5 39.7-19.5 61.7 0 29 21.5 52.6 48 52.6s48-23.6 48-52.6c0-22-10.3-43.1-19.5-61.7-5.8-12.1-11.4-23.4-12.5-31.9zM144 416c-8.7 0-16-9.4-16-20.6 0-14.4 8.1-31.1 16-47.2 7.9 16.1 16 32.7 16 47.2 0 11.2-7.3 20.6-16 20.6z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,9 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
<path d="M716.8 352.6c-25.9-18.5-57.8-29.9-85.9-40-15.3-5.5-29.8-10.7-40.8-16.1-9.8-4.8-13.1-8.1-14.1-9.3 0-1.9 0.3-2.9 0.4-3.4 2.7-2.2 12.2-5.2 18-7 27.1-8.6 77.6-24.6 77.6-100.8 0-45.8-29.3-85.2-82.5-111.1-43.8-21.3-103.3-33-167.4-33-102.3 0-203.8 29.1-278.4 79.8-94 63.9-143.7 158-143.7 272.3 0 51.2 9.4 99.5 28 143.5 18.1 42.8 44.4 80.4 78.1 111.9 33.2 31 72.7 55.1 117.2 71.6 44.8 16.6 93.4 25 144.6 25 50.4 0 99.9-6.7 147.1-20 47.2-13.2 89.7-32.3 126.5-56.8 81.6-54 126.5-129 126.5-211.2 0-39-17.2-71-51.2-95.4zM606.3 605.9c-63.2 42-150.1 66.1-238.3 66.1-179 0-304-118.4-304-288 0-93.4 38.9-167.2 115.6-219.4 64.3-43.7 152.7-68.8 242.5-68.8 104.2 0.1 185.9 35.3 185.9 80.2 0 29.4-8.3 32-32.9 39.8-12.4 3.9-26.5 8.4-38.6 18-11.2 8.9-24.5 25.5-24.5 54.1 0 24.2 12.9 44.3 38.3 59.7 16.7 10.1 37.3 17.4 59 25.2 57.2 20.4 94.7 36.7 94.7 75.1 0 60.1-34.7 116.2-97.7 158z"></path>
<path d="M224 400c0-26.5-21.5-48-48-48s-48 21.5-48 48c0 26.5 21.5 48 48 48s48-21.5 48-48zM176 416c-8.8 0-16-7.2-16-16s7.2-16 16-16 16 7.2 16 16-7.2 16-16 16z"></path>
<path d="M240 480c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48zM240 544c-8.8 0-16-7.2-16-16s7.2-16 16-16 16 7.2 16 16-7.2 16-16 16z"></path>
<path d="M240 224c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48zM240 288c-8.8 0-16-7.2-16-16s7.2-16 16-16 16 7.2 16 16-7.2 16-16 16z"></path>
<path d="M368 160c-26.5 0-48 21.5-48 48s21.5 48 48 48c26.5 0 48-21.5 48-48s-21.5-48-48-48zM368 224c-8.8 0-16-7.2-16-16s7.2-16 16-16 16 7.2 16 16-7.2 16-16 16z"></path>
<path d="M542.1 417c-23.2-3.2-48.3 1.4-70.7 12.9-27.1 13.9-46.4 36.1-53 60.7-4.9 18.2-2.3 36.5 7.3 51.5 11.4 17.9 31.4 29.6 56.1 32.9 4.8 0.7 9.7 1 14.6 1 18.9 0 38.3-4.8 56.1-13.9 27.1-13.9 46.4-36.1 53-60.7 4.9-18.2 2.3-36.5-7.3-51.5-11.4-17.9-31.4-29.6-56.1-32.9zM574.7 493.1c-4.2 15.7-18 30.9-36.8 40.6-32.4 16.7-71.4 12.6-85.2-8.8-6-9.4-5.2-19.3-3.4-25.9 4.2-15.7 18-30.9 36.8-40.6 13.5-6.9 28.1-10.3 41.6-10.3 18.9 0 35.6 6.6 43.6 19.1 6 9.4 5.2 19.3 3.4 25.9z"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,18 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<path
d="M704 0h-640c-35.3 0-64 28.7-64 64v640c0 35.3 28.7 64 64 64h640c35.3 0 64-28.7 64-64v-640c0-35.3-28.7-64-64-64zM704 704h-640l-0.1-640c0 0 0 0 0.1 0h640v640z"
></path>
<path
d="M112 544h544c8.8 0 16-7.2 16-16v-416c0-8.8-7.2-16-16-16h-544c-8.8 0-16 7.2-16 16v416c0 8.8 7.2 16 16 16zM338.7 512l214.7-214.7c12.4-12.4 32.8-12.4 45.3 0l41.3 41.3v173.4h-301.3zM640 128v165.4l-18.7-18.7c-25-24.9-65.6-24.9-90.5 0l-237.3 237.3h-146.8l150.7-150.7c12.4-12.4 32.8-12.4 45.3 0l29.3 29.3 22.6-22.6-29.3-29.3c-25-24.9-65.6-24.9-90.5 0l-146.8 146.8v-357.5h512z"
></path>
<path
d="M224 288c35.3 0 64-28.7 64-64s-28.7-64-64-64-64 28.7-64 64 28.7 64 64 64zM224 192c17.6 0 32 14.4 32 32s-14.4 32-32 32-32-14.4-32-32 14.4-32 32-32z"
></path>
<path d="M256 608h256v32h-256v-32z"></path>
</svg>

After

Width:  |  Height:  |  Size: 903 B

View File

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

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 649 B

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,4 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="100%" height="100%">
<path d="M22.866 1.503c-0.178-0.312-0.506-0.503-0.866-0.503-2.319 0-4.803 0.503-7.178 1.45-2.463 0.984-4.803 2.45-6.769 4.237-2.163 1.969-3.866 4.294-5.066 6.906-1.319 2.881-1.987 6.047-1.987 9.406h2c0-2.288 0.328-4.319 0.869-6.113 1.131-1.9 2.109-1.906 3.856-1.919 1.644-0.012 3.687-0.028 6.244-1.688 2.822-1.831 5.731-5.359 8.894-10.778 0.181-0.309 0.184-0.691 0.003-1zM18.225 6.041c-1.266 0.112-3.844 0.525-6.012 2.050l-0.409 0.288 0.575 0.819 0.409-0.288c1.534-1.078 3.341-1.547 4.609-1.753-1.572 2.037-3.069 3.506-4.516 4.447-2.066 1.341-3.644 1.353-5.169 1.366-0.756 0.006-1.522 0.012-2.275 0.2 1.181-2.137 2.631-3.784 3.966-5 3.056-2.784 6.972-4.603 10.766-5.056-0.663 1.066-1.309 2.044-1.944 2.928z"></path>
</svg>

After

Width:  |  Height:  |  Size: 881 B

View File

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

Before

Width:  |  Height:  |  Size: 746 B

After

Width:  |  Height:  |  Size: 729 B

6
src/lib/icons/tag.svelte Normal file
View File

@@ -0,0 +1,6 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="100%" height="100%">
<path d="M24 5c0-2.756-2.244-5-5-5-2.416 0-4.434 1.722-4.9 4h-2.091c-0.534 0-1.034 0.209-1.413 0.588l-10 10c-0.781 0.781-0.781 2.050 0 2.831l5.991 5.988c0.391 0.391 0.903 0.584 1.416 0.584s1.025-0.194 1.416-0.584l10-10c0.378-0.378 0.588-0.881 0.588-1.413v-2.094c2.275-0.466 3.994-2.487 3.994-4.9zM16 9v0c0 0.55-0.45 1-1 1s-1-0.45-1-1 0.45-1 1-1c0 0 0 0 0 0 0.281 0.375 0.616 0.712 1 1zM18.003 11.994l-10 10-5.994-5.994 10-10h2.091c0.075 0.372 0.194 0.734 0.35 1.075-0.837 0.237-1.453 1.009-1.453 1.925 0 1.103 0.897 2 2 2s2-0.897 2-2c0-0.897-0.594-1.659-1.413-1.913-0.206-0.337-0.363-0.703-0.462-1.088h2.878c0.003 0 0.003 0.003 0.003 0.006v5.988zM20.003 8.872v-2.878c-0.006-1.1-0.906-1.994-2.003-1.994h-2.875c0.444-1.722 2.013-3 3.875-3 2.206 0 4 1.794 4 4 0 1.859-1.275 3.425-2.997 3.872z"></path>
<path d="M4.793 16.5l4.707-4.707 0.707 0.707-4.707 4.707-0.707-0.707z"></path>
<path d="M6.794 18.499l5.708-5.708 0.707 0.707-5.708 5.708-0.707-0.707z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,5 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
<path d="M64 736h640v32h-640v-32z"></path>
<path d="M245.5 416h276.9l96.4 213.2 58.3-26.4-264-584c-5.1-11.4-16.5-18.8-29.1-18.8s-24 7.4-29.2 18.8l-264 584 58.3 26.4 96.4-213.2zM384 109.7l109.5 242.3h-219l109.5-242.3z"></path>
</svg>

After

Width:  |  Height:  |  Size: 393 B

View File

@@ -0,0 +1,5 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
<path d="M768 64h-576v64h256v576h64v-576h256z"></path>
<path d="M0 416h128v288h64v-288h128v-64h-320z"></path>
</svg>

After

Width:  |  Height:  |  Size: 277 B

View File

@@ -1,5 +1,4 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 768 768"> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 768 768">
</g>
<path d="M721.4 97.4l-368 368c-21.9 21.9-42.8 16.6-55.1 4.3s-17.6-33.2 4.3-55.1l368-368-45.2-45.2-368 368c-28.3 28.3-33.9 57.7-33.6 77.4 0.2 14.7 3.8 29 10.3 42l-153.5 153.4c-5.3-1.4-10.9-2.2-16.6-2.2-17.1 0-33.2 6.7-45.3 18.7-25 25-25 65.6 0 90.5 12.1 12.1 28.2 18.8 45.3 18.8s33.2-6.7 45.3-18.7c16.7-16.7 22.2-40.4 16.6-61.8l153.4-153.5c13 6.5 27.3 10.1 42 10.3 0.5 0 1 0 1.5 0 19.6 0 48.3-6 75.9-33.6l368-368-45.3-45.3zM86.6 726.6c-6 6-14.1 9.4-22.6 9.4s-16.6-3.3-22.6-9.4c-12.5-12.5-12.5-32.8 0-45.3 0 0 0 0 0 0 6-6 14.1-9.4 22.6-9.4s16.6 3.3 22.6 9.4c12.5 12.5 12.5 32.8 0 45.3z"></path> <path d="M721.4 97.4l-368 368c-21.9 21.9-42.8 16.6-55.1 4.3s-17.6-33.2 4.3-55.1l368-368-45.2-45.2-368 368c-28.3 28.3-33.9 57.7-33.6 77.4 0.2 14.7 3.8 29 10.3 42l-153.5 153.4c-5.3-1.4-10.9-2.2-16.6-2.2-17.1 0-33.2 6.7-45.3 18.7-25 25-25 65.6 0 90.5 12.1 12.1 28.2 18.8 45.3 18.8s33.2-6.7 45.3-18.7c16.7-16.7 22.2-40.4 16.6-61.8l153.4-153.5c13 6.5 27.3 10.1 42 10.3 0.5 0 1 0 1.5 0 19.6 0 48.3-6 75.9-33.6l368-368-45.3-45.3zM86.6 726.6c-6 6-14.1 9.4-22.6 9.4s-16.6-3.3-22.6-9.4c-12.5-12.5-12.5-32.8 0-45.3 0 0 0 0 0 0 6-6 14.1-9.4 22.6-9.4s16.6 3.3 22.6 9.4c12.5 12.5 12.5 32.8 0 45.3z"></path>
<path d="M448 544v32c68.4 0 132.7-26.6 181-75s75-112.6 75-181h-32c0 123.5-100.5 224-224 224z"></path> <path d="M448 544v32c68.4 0 132.7-26.6 181-75s75-112.6 75-181h-32c0 123.5-100.5 224-224 224z"></path>
<path d="M224 320c0-123.5 100.5-224 224-224v-32c-68.4 0-132.7 26.6-181 75s-75 112.6-75 181h32z"></path> <path d="M224 320c0-123.5 100.5-224 224-224v-32c-68.4 0-132.7 26.6-181 75s-75 112.6-75 181h32z"></path>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,4 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 768" width="100%" height="100%">
<path d="M704 64h-640c-35.3 0-64 28.7-64 64v352c0 35.3 28.7 64 64 64h288v64c0 15.1-12.4 30.6-34 42.6-24.9 13.8-58.3 21.4-94 21.4v32h320v-32c-35.8 0-69.2-7.6-94-21.4-21.6-12-34-27.5-34-42.6v-64h288c35.3 0 64-28.7 64-64v-352c0-35.3-28.7-64-64-64zM423.8 672h-79.6c24.9-16.9 39.8-39.2 39.8-64 0 24.8 14.9 47.1 39.8 64zM64 128h640v288h-640v-288c-0.1 0 0 0 0 0zM64 480v-32h640v32h-640z"></path>
</svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@@ -0,0 +1,7 @@
<!-- generated by icomoon.io - licensed Lindua icon -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="100%" height="100%">
<path d="M22 1h-20c-1.103 0-2 0.897-2 2v18c0 1.103 0.897 2 2 2h20c1.103 0 2-0.897 2-2v-18c0-1.103-0.897-2-2-2zM22 3v4h-20v-4h20zM22 8v11h-20v-11h20zM22 21h-20v-1h20.003l-0.003 1c0.003 0 0 0 0 0z"></path>
<path d="M5 5c0 0.552-0.448 1-1 1s-1-0.448-1-1c0-0.552 0.448-1 1-1s1 0.448 1 1z"></path>
<path d="M8 5c0 0.552-0.448 1-1 1s-1-0.448-1-1c0-0.552 0.448-1 1-1s1 0.448 1 1z"></path>
<path d="M11 5c0 0.552-0.448 1-1 1s-1-0.448-1-1c0-0.552 0.448-1 1-1s1 0.448 1 1z"></path>
</svg>

After

Width:  |  Height:  |  Size: 637 B

View File

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

View File

@@ -66,6 +66,45 @@ interface NodeStatus {
bootInfo: BootInfo; bootInfo: BootInfo;
} }
export interface VM {
cpu: number;
cpus: number;
disk: number;
diskread: number;
diskwrite: number;
maxdisk: number;
maxmem: number;
mem: number;
name: string;
netin: number;
netout: number;
pid: number;
status: string;
uptime: number;
vmid: number;
}
export interface LXC {
cpu: number;
cpus: number;
disk: number;
diskread: number;
diskwrite: number;
maxdisk: number;
maxmem: number;
maxswap: number;
mem: number;
name: string;
netin: number;
netout: number;
pid: number;
status: string;
swap: number;
type: string;
uptime: number;
vmid: number;
}
export interface Node { export interface Node {
info: NodeStatus; info: NodeStatus;
online: number; online: number;
@@ -76,4 +115,6 @@ export interface Node {
type: string; type: string;
ip: string; ip: string;
level: string; level: string;
vms: Array<VM>;
lxcs: Array<LXC>;
} }

View File

@@ -0,0 +1,9 @@
export interface Site {
name: string;
link: string;
image: string;
color: string;
background: string;
created?: number;
updated?: number;
}

View File

@@ -1,91 +1,5 @@
import { currentFilament } from './filament';
import pg from 'pg';
import { env } from '$env/dynamic/private';
import type { Filament } from '$lib/interfaces/printer'; import type { Filament } from '$lib/interfaces/printer';
import { getDb } from '../database';
const { Pool } = pg;
let pool: InstanceType<typeof Pool> | undefined;
async function initDb() {
if (pool) return pool;
pool = new Pool({
connectionString: env.DATABASE_URL // e.g. postgres://user:pass@localhost:5432/mydb
});
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const stmt of schemas) {
await client.query(stmt);
}
await client.query('COMMIT');
} catch (err: any) {
console.error('Failed to create tables:', err.message);
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
return pool;
}
const schemas = [
`
CREATE TABLE IF NOT EXISTS filament (
id SERIAL PRIMARY KEY,
hex TEXT NOT NULL,
color TEXT NOT NULL,
material TEXT,
weight REAL,
link TEXT,
added INTEGER, -- epoch seconds
updated INTEGER, -- epoch seconds
UNIQUE (hex, updated)
)
`
];
async function seedData(pool: InstanceType<typeof Pool>) {
const baseTimestamp = Math.floor(new Date('2025-04-01T05:47:01+00:00').getTime() / 1000);
const filaments = currentFilament();
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const f of filaments) {
await client.query(
`INSERT INTO filament (hex, color, material, weight, link, added, updated)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (hex, updated) DO NOTHING`,
[f.hex, f.color, f.material, f.weight, f.link, baseTimestamp, baseTimestamp]
);
}
await client.query('COMMIT');
} catch (err: any) {
console.error('Failed to seed data:', err.message);
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Export helper to use db elsewhere
async function getDb() {
if (pool) return pool;
const p = await initDb();
await seedData(p);
console.log('Database setup and seeding complete!');
return p;
}
export async function getAllFilament(): Promise<Array<Filament>> { export async function getAllFilament(): Promise<Array<Filament>> {
const pool = await getDb(); const pool = await getDb();

View File

@@ -0,0 +1,100 @@
import pg from 'pg';
import { env } from '$env/dynamic/private';
import type { Filament } from '$lib/interfaces/printer';
const { Pool } = pg;
let pool: InstanceType<typeof Pool> | undefined;
async function initDb() {
if (pool) return pool;
pool = new Pool({
connectionString: env.DATABASE_URL // e.g. postgres://user:pass@localhost:5432/mydb
});
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const stmt of schemas) {
await client.query(stmt);
}
await client.query('COMMIT');
} catch (err: any) {
console.error('Failed to create tables:', err.message);
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
return pool;
}
const schemas = [
`
CREATE TABLE IF NOT EXISTS filament (
id SERIAL PRIMARY KEY,
hex TEXT NOT NULL,
color TEXT NOT NULL,
material TEXT,
weight REAL,
link TEXT,
added INTEGER, -- epoch seconds
updated INTEGER, -- epoch seconds
UNIQUE (hex, updated)
)
`,
`
CREATE TABLE IF NOT EXISTS site (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
color TEXT NOT NULL,
image TEXT,
link TEXT,
background TEXT,
created INTEGER, -- epoch seconds
updated INTEGER, -- epoch seconds
UNIQUE (name, updated)
)
`
];
async function seedFilament(pool: InstanceType<typeof Pool>) {
const baseTimestamp = Math.floor(new Date('2025-04-01T05:47:01+00:00').getTime() / 1000);
const filaments: Filament[] = []; // disables seed
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const f of filaments) {
await client.query(
`INSERT INTO filament (hex, color, material, weight, link, added, updated)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (hex, updated) DO NOTHING`,
[f.hex, f.color, f.material, f.weight, f.link, baseTimestamp, baseTimestamp]
);
}
await client.query('COMMIT');
} catch (err: any) {
console.error('Failed to seed data:', err.message);
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Export helper to use db elsewhere
export async function getDb() {
if (pool) return pool;
const p = await initDb();
await seedFilament(p);
console.log('Database setup and seeding complete!');
return p;
}

View File

@@ -0,0 +1,21 @@
import { getDb } from '../database';
import type { Site } from '$lib/interfaces/site';
export async function allSites(): Promise<Array<Site>> {
const pool = await getDb();
const query = 'SELECT * FROM site';
const result = await pool.query(query);
return result.rows || [];
}
export async function addSite(site: Site) {
const timestamp = Math.floor(new Date().getTime() / 1000);
const query = `INSERT INTO site (name, link, image, color, background, updated)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`;
const { name, link, image, color, background } = site;
const pool = await getDb();
const result = await pool.query(query, [name, link, image, color, background, timestamp]);
return { id: result.rows[0].id };
}

View File

@@ -77,7 +77,7 @@ export async function fetchP1P(): Promise<PrinterState> {
let hassStates = await fetchHassStates(); let hassStates = await fetchHassStates();
hassStates = hassStates.filter( hassStates = hassStates.filter(
(el: Entity) => el.attributes.friendly_name?.includes('P1P') === true (el: Entity) => el.attributes.friendly_name?.toLowerCase()?.includes('p1p') === true
); );
return printerState(hassStates); return printerState(hassStates);
} catch (error) { } catch (error) {

View File

@@ -57,15 +57,64 @@ async function getClusterInfo() {
}); });
} }
export async function vmInfo(nodeName: string, vmId: string) {
const r = buildProxmoxRequest();
r.url += `nodes/${nodeName}/qemu/${vmId}/config`;
return fetch(r.url, r?.options)
.then(resp => resp.json())
.then((response) => response.data)
}
export async function vmCloudInit(nodeName: string, vmId: string) {
const r = buildProxmoxRequest();
vmId = 121
r.url += `nodes/${nodeName}/qemu/${vmId}/cloudinit`;
return fetch(r.url, r?.options)
.then(resp => resp.json())
.then((response) => response.data)
}
export async function vmAgentOS(nodeName: string, vmId: string) {
const r = buildProxmoxRequest();
vmId = 121
r.url += `nodes/${nodeName}/qemu/${vmId}/agent/get-osinfo`;
return fetch(r.url, r?.options)
.then(resp => resp.json())
.then((response) => response.data?.result)
}
export async function vmAgentFS(nodeName: string, vmId: string) {
const r = buildProxmoxRequest();
vmId = 121
r.url += `nodes/${nodeName}/qemu/${vmId}/agent/get-fsinfo`;
return fetch(r.url, r?.options)
.then(resp => resp.json())
.then((response) => response.data?.result)
}
export async function vmAgentNetwork(nodeName: string, vmId: string) {
const r = buildProxmoxRequest();
vmId = 121
r.url += `nodes/${nodeName}/qemu/${vmId}/agent/network-get-interfaces`;
return fetch(r.url, r?.options)
.then(resp => resp.json())
.then((response) => response.data?.result)
}
export async function fetchNodes(): Promise<{ nodes: Node[]; cluster: Cluster | null }> { export async function fetchNodes(): Promise<{ nodes: Node[]; cluster: Cluster | null }> {
try { try {
const { nodes, cluster } = await getClusterInfo(); const { nodes, cluster } = await getClusterInfo();
const infoP = Promise.all(nodes.map((node: Node) => fetchNodeInfo(node))); const infoBulk = Promise.all(nodes.map((node: Node) => fetchNodeInfo(node)));
const vmsP = Promise.all(nodes.map((node: Node) => fetchNodeVMs(node))); const vmsBulk = Promise.all(nodes.map((node: Node) => fetchNodeVMs(node)));
const lxcsP = Promise.all(nodes.map((node: Node) => fetchNodeLXCs(node))); const lxcsBulk = Promise.all(nodes.map((node: Node) => fetchNodeLXCs(node)));
const [info, vms, lxcs] = await Promise.all([infoP, vmsP, lxcsP]); const [info, vms, lxcs] = await Promise.all([infoBulk, vmsBulk, lxcsBulk]);
return { return {
cluster, cluster,

175
src/lib/styles/card.scss Normal file
View File

@@ -0,0 +1,175 @@
@keyframes pulse-live {
0% {
box-shadow: 0 0 0 0 var(--pulse);
}
70% {
box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 212, 57, 0);
}
}
@mixin pulse-dot {
&::after {
content: '';
top: 50%;
margin-left: 0.4rem;
position: absolute;
display: block;
border-radius: 50%;
background-color: var(--color);
border-radius: 50%;
transform: translate(-50%, -50%);
animation: pulse-live 2s infinite;
height: 16px;
width: 16px;
}
}
.card {
box-shadow:
0px 0px 2px #22242714,
0px 1px 4px #2224271f,
0px 4px 8px #22242729;
pointer-events: all;
cursor: auto;
}
:global(body.dark .card) {
box-shadow:
0px 0px 2px #eaddd514,
0px 1px 4px #eaddd515,
0px 4px 6px #eaddd520;
}
:global(body.dark .card .header, body.dark .card .footer) {
background-color: var(--highlight);
}
.header {
display: flex;
padding: 0.75rem;
background-color: white;
align-items: center;
font-size: 16px;
.icon {
height: 24px;
width: 24px;
margin-right: 0.75rem;
}
.subtle {
margin-left: 0.25rem;
opacity: 0.4;
font-weight: 500;
font-size: 0.9rem;
}
.status {
height: 1rem;
width: 1rem;
border-radius: 50%;
margin-left: auto;
position: relative;
@include pulse-dot;
&.ok {
--color: var(--positive);
--pulse: var(--pulse-positive);
}
&.warning {
--color: var(--warning);
--pulse: var(--pulse-warning);
}
&.error {
--color: var(--negative);
--pulse: var(--pulse-negative);
}
}
}
.footer {
padding: 0.5rem;
background-color: white;
}
.resource {
display: grid;
grid-template-columns: auto auto;
padding: 0.5rem;
background-color: var(--bg);
row-gap: 6px;
max-width: 330px;
> div,
span {
display: flex;
padding: 0 0.5rem;
}
}
:global(.resource .title svg) {
height: 1rem;
width: 1rem;
}
.footer {
display: flex;
align-items: center;
justify-content: space-evenly;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: auto;
background: white;
padding: 0.5rem;
border-bottom-left-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
button {
border: none;
position: relative;
background: transparent;
height: unset;
border-radius: 0.5rem;
display: inline-block;
text-decoration: none;
padding: 0 0.5rem;
flex: 1;
span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 1.5rem;
padding: 0 0.5rem;
margin-left: -0.5rem;
border: 1px solid #eaddd5;
border-radius: inherit;
white-space: nowrap;
cursor: pointer;
font-weight: 700;
}
&::after {
content: '';
position: absolute;
right: 0;
top: 0;
border-radius: 0.5rem;
width: 100%;
height: 100%;
transition: transform 0.1s ease;
will-change: box-shadow 0.25s;
pointer-events: none;
}
}
}
.positive {
color: #077c35;
}

266
src/lib/utils/dns.ts Normal file
View File

@@ -0,0 +1,266 @@
import dns from 'dns';
import net from 'net';
let timeout = 0;
const axfrReqProloge =
'\x00\x00' + // Size
'\x00\x00' + // Transaction ID
'\x00\x20' + // Flags: Standard Query
'\x00\x01' + // Number of questions
'\x00\x00' + // Number of answers
'\x00\x00' + // Number of Authority RRs
'\x00\x00'; // Number of Additional RRs
const axfrReqEpiloge =
'\x00' + // End of name
'\x00\xfc' + // Type: AXFR
'\x00\x01'; // Class: IN
function inet_ntoa(num: number): string {
const nbuffer = new ArrayBuffer(4);
const ndv = new DataView(nbuffer);
ndv.setUint32(0, num);
const a: number[] = [];
for (let i = 0; i < 4; i++) {
a[i] = ndv.getUint8(i);
}
return a.join('.');
}
interface DecompressedLabel {
len: number;
name: string;
next?: number;
}
function decompressLabel(data: Buffer, offset: number): DecompressedLabel {
const res: DecompressedLabel = { len: 0, name: '' };
let loffset = offset;
let tmpoff = 0;
while (data[loffset] !== 0x00) {
// Check for pointers
if ((data[loffset] & 0xc0) === 0xc0) {
const newoffset = data.readUInt16BE(loffset) & 0x3fff;
const label = decompressLabel(data, newoffset + 2);
res.name += label.name;
loffset += 1;
break;
} else {
// Normal label
tmpoff = loffset + 1;
res.name += data.toString('utf8', tmpoff, tmpoff + data[loffset]);
res.name += '.';
loffset += data[loffset] + 1;
}
}
res.next = loffset + 1;
return res;
}
interface DNSResult {
questions: any[];
answers: any[];
}
function parseResponse(response: Buffer, result: DNSResult): DNSResult | number {
let offset = 14;
let len = response.readUInt16BE(0);
if (response.length !== len + 2) return -1; // Invalid length
if ((response[4] & 0x80) !== 0x80) return -2; // Not a query response
if ((response[5] & 0x0f) !== 0) return -3; // Error code present
const questions = response.readUInt16BE(6);
const answers = response.readUInt16BE(8);
const authRRs = response.readUInt16BE(10);
const aditRRs = response.readUInt16BE(12);
// Parse queries
for (let x = 0; x < questions; x++) {
const entry = decompressLabel(response, offset);
result.questions.push({
name: entry.name,
type: 'AXFR'
});
offset = (entry.next ?? 0) + 4;
}
// Parse answers
for (let x = 0; x < answers; x++) {
let entry: any = {};
entry = decompressLabel(response, offset);
offset = entry.next!;
entry.name = entry.name;
const type = response.readUInt16BE(offset);
const rclass = response.readUInt16BE(offset + 2);
entry.ttl = response.readUInt32BE(offset + 4);
const rlen = response.readUInt16BE(offset + 8);
// Skip non-INET
if (rclass !== 0x01) {
offset += rlen + 10;
continue;
}
switch (type) {
case 0x01: // A
entry.type = 'A';
entry.a = inet_ntoa(response.readUInt32BE(offset + 10));
break;
case 0x02: // NS
entry.type = 'NS';
entry.ns = decompressLabel(response, offset + 10).name;
break;
case 0x05: // CNAME
entry.type = 'CNAME';
entry.cname = decompressLabel(response, offset + 10).name;
break;
case 0x06: {
// SOA
entry.type = 'SOA';
let tentry = decompressLabel(response, offset + 10);
entry.dns = tentry.name;
tentry = decompressLabel(response, tentry.next!);
entry.mail = tentry.name;
entry.serial = response.readUInt32BE(tentry.next!);
entry.refreshInterval = response.readUInt32BE(tentry.next! + 4);
entry.retryInterval = response.readUInt32BE(tentry.next! + 8);
entry.expireLimit = response.readUInt32BE(tentry.next! + 12);
entry.minTTL = response.readUInt32BE(tentry.next! + 16);
break;
}
case 0x0f: // MX
entry.type = 'MX';
entry.pref = response.readUInt16BE(offset + 10);
entry.mx = decompressLabel(response, offset + 12).name;
break;
case 0x10: // TXT
entry.type = 'TXT';
len = response[offset + 10];
entry.txt = response.toString('utf8', offset + 11, offset + 11 + len);
break;
case 0x1c: {
// AAAA
entry.type = 'AAAA';
// const byteArr = new Uint8Array(response.slice(offset + 10, offset + 26));
// entry.aaaa = fromByteArray(byteArr).toString();
console.warn('unable to parse AAAA DNS records');
entry.aaaa = '::';
break;
}
case 0x63: // SPF
entry.type = 'SPF';
len = response[offset + 10];
entry.txt = response.toString('utf8', offset + 11, offset + 11 + len);
break;
case 0x21: // SRV
entry.type = 'SRV';
entry.priority = response.readUInt16BE(offset + 10);
entry.weight = response.readUInt16BE(offset + 12);
entry.port = response.readUInt16BE(offset + 14);
entry.target = decompressLabel(response, offset + 16).name;
break;
}
delete entry.len;
delete entry.next;
result.answers.push(entry);
offset += rlen + 10;
}
return result;
}
(dns as any).resolveAxfrTimeout = (milis: number): void => {
timeout = milis;
};
(dns as any).resolveAxfr = (
server: string,
domain: string,
callback: (code: number, result: any) => void
): void => {
const buffers: Buffer[] = [];
const split = domain.split('.');
const results: DNSResult = { questions: [], answers: [] };
let responses: Buffer[] = [];
let len = 0;
let tlen = 0;
const [hostname, portStr] = server.split(':');
const port = Number(portStr) || 53;
// Build request
buffers.push(Buffer.from(axfrReqProloge, 'binary'));
split.forEach((elem) => {
const label = Buffer.from('\x00' + elem, 'utf8');
label.writeUInt8(elem.length, 0);
buffers.push(label);
});
buffers.push(Buffer.from(axfrReqEpiloge, 'binary'));
const buffer = Buffer.concat(buffers);
// Set size and transaction ID
buffer.writeUInt16BE(buffer.length - 2, 0);
buffer.writeUInt16BE(Math.floor(Math.random() * 65535 + 1), 2);
// Connect and send request
const socket = net.connect(port, hostname, () => {
socket.write(buffer.toString('binary'), 'binary');
socket.end();
});
if (timeout) socket.setTimeout(timeout);
socket.on('data', (data: Buffer) => {
if (len === 0) len = data.readUInt16BE(0);
responses.push(data);
tlen += data.length;
if (tlen >= len + 2) {
const buf = Buffer.concat(responses, tlen);
const tmpBuf = buf.slice(0, len + 2);
const res = parseResponse(tmpBuf, results);
if (typeof res !== 'object') {
socket.destroy();
callback(res, 'Error on response');
}
}
if (tlen > len + 2) {
const buf = Buffer.concat(responses, tlen);
const tmpBuf = buf.slice(len + 2);
len = tmpBuf.readUInt16BE(0);
tlen = tmpBuf.length;
responses = [tmpBuf];
}
});
socket.on('timeout', () => {
socket.destroy();
callback(-5, 'Timeout');
});
socket.on('end', () => {
callback(0, results);
});
socket.on('error', () => {
callback(-4, 'Error connecting');
});
};
export default dns;

View File

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

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import PageElement from '$lib/components/PageElement.svelte'; import PageElement from '$lib/components/PageElement.svelte';
import { onMount } from 'svelte';
let elems = []; let elems = [];
let counter = 0; let counter = 0;
@@ -28,10 +29,10 @@
return { return {
bgColor: colors[counter - 1][0], bgColor: colors[counter - 1][0],
color: colors[counter - 1][1], color: colors[counter - 1][1],
title, name: title,
header: null, header: null,
description: description ? description : '', description: description ? description : '',
link: title link: `/${title}`
}; };
} }
@@ -59,11 +60,13 @@
elems = elems.concat(createPageElement('cluster ')); elems = elems.concat(createPageElement('cluster '));
elems = elems.concat(createPageElement('health ')); elems = elems.concat(createPageElement('health '));
onMount(() => window.elements = elems)
</script> </script>
<PageHeader>Welcome to schleppe.cloud infra overview</PageHeader> <PageHeader>Welcome to schleppe.cloud infra overview</PageHeader>
<p> <p class="site-desc">
This site is a local-first dashboard for monitoring the state of digital and physical tools in a This site is a local-first dashboard for monitoring the state of digital and physical tools in a
workshop environment. It currently tracks servers (IP, cores, memory, uptime), 3D printers workshop environment. It currently tracks servers (IP, cores, memory, uptime), 3D printers
(status, history, filament stock), and other connected devices. Each device or system has its own (status, history, filament stock), and other connected devices. Each device or system has its own
@@ -71,7 +74,7 @@
general monitoring tools, IoT integrations, and project overviews. general monitoring tools, IoT integrations, and project overviews.
</p> </p>
<p> <p class="site-desc">
The system is intended for hybrid spaces where digital infrastructure coexists with hands-on work. The system is intended for hybrid spaces where digital infrastructure coexists with hands-on work.
Alongside real-time monitoring, Schleppe is expanding to reflect the broader physical Alongside real-time monitoring, Schleppe is expanding to reflect the broader physical
workspace—covering areas like tool usage, material stocks, and workstations for welding, workspace—covering areas like tool usage, material stocks, and workstations for welding,
@@ -80,11 +83,11 @@
</p> </p>
<div class="shortcut-grid"> <div class="shortcut-grid">
{#each elems as shortcut (shortcut.title)} {#each elems as shortcut (shortcut.name)}
<PageElement <PageElement
bgColor={shortcut.bgColor} bgColor={shortcut.bgColor}
color={shortcut.color} color={shortcut.color}
title={shortcut.title} title={shortcut.name}
header={shortcut.header} header={shortcut.header}
description={shortcut.description} description={shortcut.description}
link={shortcut.link} link={shortcut.link}
@@ -93,18 +96,23 @@
</div> </div>
<style lang="scss"> <style lang="scss">
p { p.site-desc {
font-size: 1.1rem; font-size: 1.1rem;
line-height: 1.4; line-height: 1.4;
line-height: 1.7; line-height: 1.7;
color: #333; color: #333;
background-color: #fafafa; /* Subtle background to separate it from the rest */ background-color: #fafafa;
padding: 2rem; padding: 2rem;
border-radius: 1rem; /* Soft edges */ border-radius: 1rem; /* Soft edges */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); /* Light shadow for depth */ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); /* Light shadow for depth */
} }
:global(body.dark p.site-desc) {
background-color: var(--highlight);
color: var(--color);
}
.shortcut-grid { .shortcut-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import Node from '$lib/components/Node.svelte'; import Node from '$lib/components/Node.svelte';
import Deploy from '$lib/components/Deploy.svelte'; import Deploy from '$lib/components/Deploy.svelte';
import Daemon from '$lib/components/Daemon.svelte'; import Daemon from '$lib/components/Daemon.svelte';
@@ -14,10 +15,22 @@
const rawDaemons: V1DaemonSet[] = data?.daemons; const rawDaemons: V1DaemonSet[] = data?.daemons;
const rawNodes: V1Node[] = data?.nodes; const rawNodes: V1Node[] = data?.nodes;
let filterLC = $derived(filterValue.toLowerCase()) let filterLC = $derived(filterValue.toLowerCase());
let deployments = $derived(rawDeployments.filter((d) => d.metadata.name.includes(filterLC))); let deployments = $derived(rawDeployments.filter((d) => d.metadata.name.includes(filterLC)));
let daemons = $derived(rawDaemons.filter((d) => d.metadata.name.includes(filterLC))); let daemons = $derived(rawDaemons.filter((d) => d.metadata.name.includes(filterLC)));
let nodes = $derived(rawNodes.filter((n) => n.metadata.name.includes(filterLC))); let nodes = $derived(rawNodes.filter((n) => n.metadata.name.includes(filterLC)));
onMount(() => {
window.elements = deployments
.map((d) => {
return {
name: d.metadata?.name || undefined,
link: `/cluster/deployment/${d.metadata?.uid}`,
...d
};
})
.filter((d) => d.name);
});
</script> </script>
<PageHeader>Cluster overview</PageHeader> <PageHeader>Cluster overview</PageHeader>

View File

@@ -0,0 +1,63 @@
import { exec } from 'child_process';
import dns from '$lib/utils/dns';
import type { Record } from '$lib/interfaces/DNS';
import type { PageServerLoad } from './$types';
// Regex for IPv4 validation
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$/;
function ping(data: string): Promise<boolean> {
if (!ipv4Regex.test(data)) return;
const ip = data;
console.log(`Starting ping for ip: ${ip}`);
const resolver = '10.0.0.72';
return new Promise((resolve, reject) => {
exec(
`ping -c 1 -t 1.5 -W 1 ${ip} > /dev/null 2>&1 && exit 0 || exit 1`,
(error, stdout, stderr) => {
if (error) {
console.error(`❌ Exec error: ${error.message}`);
reject(error);
}
if (stderr) {
console.error(`⚠️ Stderr: ${stderr}`);
reject(error);
}
console.log('✅ Ping response received:', stdout);
resolve(true);
}
);
});
}
function axfrAsync(server: string, domain: string) {
return new Promise((resolve, reject) => {
dns.resolveAxfr(server, domain, (err, resp) => {
if (err) reject(err);
resolve(resp?.answers);
});
});
}
export const load: PageServerLoad = async () => {
const server = '10.0.0.72';
const domain = 'schleppe';
const records = await axfrAsync(server, domain);
// console.log('records::', records);
const ARecords = records?.filter((r) => r?.type === 'A');
for (let i = 0; i < ARecords.length; i++) {
try {
// const a = await ping(ARecords[i].a);
records[i]['available'] = true;
} catch (_) { }
}
return {
records
};
};

163
src/routes/dns/+page.svelte Normal file
View File

@@ -0,0 +1,163 @@
<script lang="ts">
import Search from '$lib/icons/search.svelte';
import Input from '$lib/components/Input.svelte';
import Table from '$lib/components/Table.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import FormDNS from '$lib/components/forms/FormDNS.svelte';
import type { Record } from '$lib/interfaces/DNS';
import type { PageData } from '../$types';
let { data }: { data: PageData } = $props();
let recordsFilter = $state('');
let recordsSort = $state('type');
let open = $state(false);
const rawRecords: Record[] = data?.records || [];
let records = $derived(
rawRecords
?.filter(
(r: Record) =>
r.name?.toLowerCase()?.includes(recordsFilter) ||
r.data?.toLowerCase()?.includes(recordsFilter)
)
.sort((a, b) => (a?.[recordsSort] < b?.[recordsSort] ? -1 : 1))
);
</script>
<h1>DNS</h1>
<Table
title="Zonal records"
description="schleppe colors are currently in stock. Overview of currently stocked filament."
columns={['Ping', 'Details', 'TTL']}
data={records}
footer="Last updated on ~some date~"
>
<div slot="actions" class="filament-table-inputs">
<div>
<Input
placeholder={`${records?.[0]?.name || 'record'}`}
icon={Search}
bind:value={recordsFilter}
label="Records filter"
/>
</div>
<button class="affirmative" on:click={() => (open = true)}><span>Add new</span></button>
</div>
<tbody slot="tbody">
{#each records as row (row)}
<tr>
<td>{row?.available === true ? 'up' : 'down'}</td>
<td class="info">
<div class="meta">
<span>name: {row.name}</span>
<span>type: {row.type}</span>
<span>addr: {row.a}</span>
</div>
</td>
<td>{row.ttl}</td>
</tr>
{/each}
</tbody>
</Table>
{#if open}
<Dialog
title="Add DNS record"
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
close={() => (open = false)}
>
<FormDNS close={() => (open = false)} />
</Dialog>
{/if}
<style lang="scss">
.section-element {
.icon {
display: inline-block;
--size: 2rem;
height: var(--size);
width: var(--size);
padding-right: 0.5rem;
&.spin {
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
animation: rotate 6s linear infinite;
transform-origin: calc((var(--size) / 2) - 2px) calc(var(--size) / 2);
}
}
}
.progress {
display: flex;
flex-direction: column;
width: 100%;
span {
margin-top: 0.5rem;
}
}
.filament-table-inputs {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
> div {
max-width: 450px;
}
> button {
flex: unset;
height: 2.6rem;
}
}
/* filament table */
tr td {
&:first-of-type {
}
&.info {
display: table-cell;
vertical-align: middle;
h2 {
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.45em;
font-size: 1.1rem;
font-weight: 300;
color: var(--theme);
}
.meta {
display: flex;
gap: 0.3rem;
flex-direction: column;
opacity: 0.6;
word-break: break-all;
}
}
}
.color {
--size: 4rem;
display: block;
width: var(--size);
height: var(--size);
border-radius: var(--border-radius, 1rem);
}
</style>

View File

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

View File

@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { fetchP1P } from '$lib/server/homeassistant'; import { fetchP1P } from '$lib/server/homeassistant';
import { getAllFilament } from '$lib/server/database'; import { getAllFilament } from '$lib/server/database/filament';
import type { Filament } from '$lib/interfaces/printer'; import type { Filament } from '$lib/interfaces/printer';
interface PrinterState { interface PrinterState {

View File

@@ -46,6 +46,7 @@
let open = $state(false); let open = $state(false);
let timeLeftInterval: ReturnType<typeof setInterval>; let timeLeftInterval: ReturnType<typeof setInterval>;
console.log("got data:", data)
const rawFilament: Filament[] = data?.filament || []; const rawFilament: Filament[] = data?.filament || [];
let filament = $derived( let filament = $derived(
rawFilament rawFilament
@@ -246,7 +247,7 @@
</div> </div>
<tbody slot="tbody"> <tbody slot="tbody">
{#each filament as row, i (row)} {#each filament as row (row)}
<tr class="link" on:click={() => goto(filamentLink(row))}> <tr class="link" on:click={() => goto(filamentLink(row))}>
<td><span class="color" style={`background: ${row.hex}`} /></td> <td><span class="color" style={`background: ${row.hex}`} /></td>
<td class="info"> <td class="info">
@@ -267,7 +268,7 @@
{#if open} {#if open}
<Dialog <Dialog
on:close={() => (open = false)} close={() => (open = false)}
title="Add new filament" title="Add new filament"
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service." description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
> >
@@ -334,7 +335,6 @@
&.info { &.info {
display: table-cell; display: table-cell;
vertical-align: middle; vertical-align: middle;
padding-left: 25px;
h2 { h2 {
width: 100%; width: 100%;

View File

@@ -1,4 +1,4 @@
import { addFilament, updateFilament } from '$lib/server/database'; import { addFilament, updateFilament } from '$lib/server/database/filament';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
export const PUT: RequestHandler = async ({ params, request }) => { export const PUT: RequestHandler = async ({ params, request }) => {

View File

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

View File

@@ -1,12 +1,43 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import ServerComp from '$lib/components/Server.svelte'; import ServerComp from '$lib/components/Server.svelte';
import ServerSummary from '$lib/components/ServerSummary.svelte'; import ServerSummary from '$lib/components/ServerSummary.svelte';
import VMComp from '$lib/components/VM.svelte';
import LXCComp from '$lib/components/LXC.svelte';
import type { VM, LXC } from '$lib/components/VM.svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const { cluster, nodes } = data; const { cluster, nodes } = data;
const allVms: Array<VM> = nodes.flatMap((n) => n.vms).filter((v) => v.template !== 1);
const allLxcs: Array<LXC> = nodes.flatMap((n) => n.lxcs);
onMount(() => {
window.elements = [
...allVms
.map((vm) => {
return {
link: `/servers/vm/${vm.vmid}`,
...vm
};
})
.filter((d) => d.name),
...nodes.map((node) => {
return {
link: `/servers/node/${node.name}`,
...node
};
}),
...allLxcs.map((lxc) => {
return {
link: `/servers/lxc/${lxc.vmid}`,
...lxc
};
})
];
});
</script> </script>
<PageHeader>Servers</PageHeader> <PageHeader>Servers</PageHeader>
@@ -15,18 +46,63 @@
<div class="server-list"> <div class="server-list">
{#each nodes as node (node.name)} {#each nodes as node (node.name)}
<div> <ServerComp {node} />
<ServerComp {node} />
</div>
{/each} {/each}
</div> </div>
<details open>
<summary>
<h2>VMs</h2>
</summary>
<div class="vm-list">
{#each allVms as vm (vm.vmid)}
<VMComp {vm} />
{/each}
</div>
</details>
<details open>
<summary>
<h2>LXCs</h2>
</summary>
<div class="vm-list">
{#each allLxcs as lxc (lxc)}
<LXCComp {lxc} />
{/each}
</div>
</details>
<style lang="scss"> <style lang="scss">
.server-list { *:not(:last-child) {
margin-bottom: 2rem;
}
.server-list,
.vm-list {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
align-items: left; align-items: left;
gap: 2rem; gap: 2rem;
&:not(:last-of-type) {
margin-bottom: 2rem;
}
}
h2 {
font-weight: 500;
}
details summary::-webkit-details-marker,
details summary::marker {
display: none;
}
details > summary {
list-style: none;
cursor: pointer;
} }
</style> </style>

View File

@@ -0,0 +1,82 @@
import {
fetchNodes,
vmInfo,
vmCloudInit,
vmAgentOS,
vmAgentFS,
vmAgentNetwork
} from '$lib/server/proxmox';
import type { Node, VM } from '$lib/interfaces/proxmox';
import type { PageServerLoad } from './$types';
const AVAILABLE_RESOURCES = ['node', 'vm', 'lxc'];
const filterResources = (resource: Node | VM, id) => {
if (resource?.name && resource.name === id) {
return resource;
}
return null;
};
export const load: PageServerLoad = async ({ params }) => {
const { kind, id } = params;
console.log('KIND', kind);
if (!AVAILABLE_RESOURCES.includes(kind)) {
return {
error: 'No resource ' + kind,
resource: null
};
}
console.log(params.id);
const cluster = await fetchNodes();
let vm = [];
let nodeId;
const resources: Array<Node | VM> = [];
switch (kind) {
case 'node':
vm = cluster?.nodes.find((n) => n.name === id) || resources;
break;
case 'vm':
nodeId = cluster.nodes.find(
(n) => n.vms.filter((vm) => String(vm.vmid) === id)?.length > 0
)?.name;
const clusterVM = cluster.nodes.flatMap((n) => n.vms)?.find((vm) => String(vm.vmid) === id);
if (!nodeId || !clusterVM.vmid) return;
const [cloudInit, os, fs, network] = await Promise.all([
vmCloudInit(nodeId, clusterVM.vmid),
vmAgentOS(nodeId, clusterVM.vmid),
vmAgentFS(nodeId, clusterVM.vmid),
vmAgentNetwork(nodeId, clusterVM.vmid)
]);
vm = {
config: await vmInfo(nodeId, clusterVM.vmid),
cloudInit,
os,
fs,
network,
...clusterVM
};
// resources = [vm]
break;
case 'lxc':
vm = cluster.nodes.flatMap((n) => n.lxcs)?.find((lxc) => String(lxc.vmid) === id);
break;
default:
console.log('no resources found');
}
// console.log('returning', vm);
return {
resource: vm,
kind: params.kind,
id: params.id,
error: null
};
};

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import JsonViewer from '$lib/components/JsonViewer.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import VMDescribe from '$lib/components/prox-describe/VM.svelte';
import External from '$lib/icons/external.svelte';
import Link from '$lib/icons/link.svelte';
import type { Node, VM } from '$lib/interfaces/proxmox';
import type { PageData, PageProps } from './$types';
let { data }: { data: PageData } = $props();
const { error, kind } = data;
const { resource }: { vm: VM; node: Node } = data;
console.log('RESOURCE', resource);
let res = $state(data.kind);
const template = `https://apollo.schleppe:8006/#v1:0:=qemu%2F${resource?.vmid}:4:::::::`
console.log('kind res:', res);
</script>
<PageHeader>{kind || 'Resource'}: {resource?.name || 'not found'}</PageHeader>
{#if error}
<p>{error}</p>
{/if}
{#if resource}
<div class="title">
<a href={template}>View in proxmox</a>
<span class="link"><External /></span>
</div>
{#if kind == 'vm'}
<VMDescribe vm={resource} />
{:else}
<p>{kind} stuffs</p>
<JsonViewer json={resource} />
{/if}
{:else}
<h2>404. '{kind}' resource not found!</h2>
{/if}
<style lang="scss">
.title {
font-size: 1.1rem;
font-family: 'Reckless Neue';
}
.link {
display: inline-block;
height: 1rem;
width: 1rem;
}
</style>

View File

@@ -0,0 +1,49 @@
import { allSites, addSite } from '$lib/server/database/sites';
import type { Site } from '$lib/interfaces/site';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (): Promise<{ sites: Array<Site> }> => {
let sites: Site[] = [];
try {
sites = await allSites();
console.log('got sites:', sites);
} catch (error) {
console.error('error while fetching sites server props, likely db issue');
console.error(error);
}
return { sites };
};
export const actions = {
default: async ({ request }) => {
try {
const formData = await request.formData();
// Extract values by input `name` attributes
const name = formData.get('Name')?.toString().trim();
const link = formData.get('Link')?.toString().trim();
const image = formData.get('Image')?.toString().trim();
const color = formData.get('Color')?.toString().trim();
const background = formData.get('Background')?.toString().trim();
if (!name || !link || !image || !color || !background) {
return { error: 'All fields are required!', success: false, statusCode: 400 };
}
if (!name || !link) {
return { error: 'name & link are required', success: false, statusCode: 400 };
}
const site: Site = { name, link, image, color, background };
await addSite(site);
return { success: true };
} catch (err: unknown) {
console.log(err);
console.error('Failed to add site:', err.message);
return { error: 'internal server error', success: false, statusCode: 500 };
}
}
} satisfies Actions;

View File

@@ -1,103 +1,28 @@
<script lang="ts"> <script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import Section from '$lib/components/Section.svelte'; import Section from '$lib/components/Section.svelte';
import FormSite from '$lib/components/forms/FormSite.svelte';
import ThumbnailButton from '$lib/components/ThumbnailButton.svelte'; import ThumbnailButton from '$lib/components/ThumbnailButton.svelte';
import type { Site } from '$lib/interfaces/site.ts';
import { onMount } from 'svelte';
interface Site { let { data }: { data: { site: Site } } = $props();
title: string; let open = $state(false);
image: string;
link: string;
background?: string;
color?: string;
}
const sites: Array<Site> = [ const { sites } = data;
{
title: 'Grafana', onMount(() => window.elements = sites)
image: '/images/grafana.png',
link: 'https://grafana.schleppe.cloud',
background: '#F5E3DC',
color: '#F05A24'
},
{
title: 'Prometheus',
image: '/images/prometheus.svg',
link: 'http://prome.schleppe:9090',
background: '#262221',
color: '#F3BFA2'
},
{
title: 'Traefik',
image: '/images/traefik.png',
link: 'https://grafana.schleppe.cloud',
background: '#30A4C2',
color: 'white'
},
{
title: 'Kibana',
image: '/images/kibana.svg',
link: 'https://kibana.schleppe.cloud',
background: '#f6cfdd',
color: '#401C26'
},
{
title: 'HASS',
image: '/images/hass.png',
link: 'http://homeassistant.schleppe:8123',
background: '#1ABCF2',
color: 'white'
},
{
title: 'Vault',
image: '/images/vault.svg',
link: 'http://vault.schleppe:8200',
background: 'white',
color: 'black'
},
{
title: 'Drone',
image: '/images/drone.png',
link: 'https://drone.schleppe.cloud',
background: '#D8E2F0',
color: '#1E375A'
},
{
title: 'Immich',
image: '/images/immich.png',
link: 'http://immich.schleppe:2283',
background: 'white',
color: 'black'
},
{
title: 'Wiki',
image: '/images/xwiki.png',
link: 'https://wiki.schleppe.cloud',
background: 'white',
color: 'black'
},
{
title: 'Gitea',
image: '/images/gitea.png',
link: 'https://git.schleppe.cloud',
background: '#E6E7D7',
color: '#609925'
},
{
title: 'PBS',
image: '/images/proxmox.png',
link: 'https://clio.schleppe:8007',
background: '#EDE1D2',
color: '#E66B00'
}
];
</script> </script>
<PageHeader>Sites</PageHeader> <PageHeader>Sites
<button class="add-site-btn affirmative" on:click={() => (open = true)}><span>Add new site</span></button>
</PageHeader>
<div class="section-wrapper"> <div class="section-wrapper">
{#each sites as site} {#each sites as site (site)}
<ThumbnailButton <ThumbnailButton
title={site.title} title={site.name}
image={site.image} image={site.image}
background={site.background} background={site.background}
color={site.color} color={site.color}
@@ -106,27 +31,15 @@
{/each} {/each}
</div> </div>
<div class="section-wrapper full-width"> {#if open}
<Section <Dialog
title="Expose HTTP traffic" close={() => (open = false)}
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." title="Add new site"
/> description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
>
<Section <FormSite on:close={() => (open = false)} />
title="IP restrictions" </Dialog>
description="Restrict or block access to your application based on specific IP addresses or CIDR blocks." {/if}
/>
<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."
/>
<Section
title="Connected services"
description="Connected services can communicate with your application over the private network."
/>
</div>
<style lang="scss"> <style lang="scss">
.section-wrapper { .section-wrapper {
@@ -149,4 +62,10 @@
margin-top: 4rem; margin-top: 4rem;
} }
} }
:global(button.add-site-btn) {
font-size: 1.2rem;
float: right;
height: 2.5rem;
}
</style> </style>

BIN
static/images/bitwarden.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

37
static/images/brew.svg Normal file
View File

@@ -0,0 +1,37 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="200.000000pt" height="200.000000pt" viewBox="0 0 200.000000 200.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,200.000000) scale(0.100000,-0.100000)"
fill="#F4400E" stroke="none">
<path d="M956 1798 c-9 -12 -16 -39 -16 -60 0 -23 -5 -38 -12 -39 -166 -9
-356 -117 -473 -269 -50 -65 -121 -206 -130 -257 -3 -18 -9 -42 -13 -55 -5
-13 -10 -45 -11 -73 -2 -27 -5 -53 -7 -56 -5 -9 31 -40 53 -44 10 -2 30 -4 44
-4 14 -1 26 -7 26 -13 1 -92 68 -295 107 -327 15 -12 69 -12 115 0 8 3 20 -14
31 -47 40 -115 127 -235 230 -317 83 -66 109 -70 166 -28 123 90 227 227 264
349 l13 43 59 -7 c42 -5 62 -3 74 7 28 23 86 173 98 255 12 78 17 93 31 88 7
-3 31 0 53 5 35 10 40 15 44 46 2 19 -2 63 -10 97 -7 35 -15 74 -18 88 -3 14
-27 70 -54 125 -100 205 -282 347 -492 386 l-68 12 0 42 c0 28 -6 47 -18 58
-26 24 -68 21 -86 -5z m159 -223 c59 -10 173 -66 233 -114 109 -87 194 -227
227 -375 l6 -29 -50 6 c-106 13 -244 77 -322 151 -48 45 -102 117 -135 179
-32 62 -64 87 -96 74 -9 -3 -35 -40 -58 -83 -41 -75 -173 -224 -198 -224 -6 0
-12 -4 -14 -8 -3 -10 -109 -61 -142 -69 -61 -15 -136 -26 -142 -20 -12 11 44
166 82 228 88 145 239 258 380 283 15 3 28 7 31 9 6 6 152 1 198 -8z m-56
-371 c29 -36 79 -86 112 -111 50 -39 59 -51 59 -77 0 -43 -28 -120 -66 -182
-30 -47 -149 -174 -165 -174 -4 0 -38 31 -76 69 -80 80 -140 187 -149 266 l-6
50 60 47 c33 26 84 76 113 112 29 36 55 66 59 66 4 0 30 -30 59 -66z m405
-292 c-15 -101 -52 -205 -70 -198 -7 3 -32 10 -56 16 -81 22 -81 23 -49 88 16
31 37 82 45 113 l17 56 59 -19 60 -20 -6 -36z m-801 41 c10 -55 16 -72 46
-132 27 -55 28 -62 13 -70 -14 -7 -119 -41 -128 -41 -4 0 -24 64 -22 70 1 3
-4 14 -10 26 -6 11 -14 34 -17 50 -19 101 -20 99 29 108 16 3 31 8 34 11 3 3
15 5 28 5 16 0 23 -7 27 -27z m198 -330 c50 -48 117 -93 138 -93 23 0 68 30
128 84 43 39 56 46 78 40 23 -6 26 -10 20 -33 -8 -32 -59 -144 -77 -168 -28
-36 -140 -143 -150 -143 -6 0 -43 33 -82 73 -100 102 -171 251 -127 269 27 11
31 9 72 -29z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="InfluxData_Symbol_Only" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px" y="0px" width="900px" height="900px" viewBox="-173 -143 900 900" style="enable-background:new -173 -143 900 900;"
xml:space="preserve">
<style type="text/css">
.st0{fill:none;}
.st1{fill:#22ADF6;}
</style>
<rect id="Background" x="-173" y="-143" class="st0" width="900" height="900"/>
<path id="Cuboctahedron" class="st1" d="M694.1,394.9l-81-352.7C608.5,22.9,591,3.6,571.7-2L201.5-116.2c-4.6-1.8-10.1-1.8-15.7-1.8
c-15.7,0-32.2,6.4-43.3,15.7l-265.2,246.8c-14.7,12.9-22.1,38.7-17.5,57.1l86.6,377.6c4.6,19.3,22.1,38.7,41.4,44.2l346.2,106.8
c4.6,1.8,10.1,1.8,15.7,1.8c15.7,0,32.2-6.4,43.3-15.7L676.6,453C691.4,439.2,698.7,414.3,694.1,394.9z M240.2-32.4l254.1,78.3
c10.1,2.8,10.1,7.4,0,10.1L360.8,86.4c-10.1,2.8-23.9-1.8-31.3-9.2l-93-100.4C228.2-31.4,230-35.1,240.2-32.4z M398.5,423.5
c2.8,10.1-3.7,15.7-13.8,12.9l-274.4-84.7c-10.1-2.8-12-11.1-4.6-18.4L315.7,138c7.4-7.4,15.7-4.6,18.4,5.5L398.5,423.5z
M-53.6,174.8L169.3-32.4c7.4-7.4,19.3-6.4,26.7,0.9L307.4,89.2c7.4,7.4,6.4,19.3-0.9,26.7L83.6,323.1c-7.4,7.4-19.3,6.4-26.7-0.9
L-54.5,201.6C-61.9,193.3-60.9,181.3-53.6,174.8z M0.8,503.6l-58.9-258.8c-2.8-10.1,1.8-12,8.3-4.6l93,100.4
c7.4,7.4,10.1,22.1,7.4,32.2L10,503.6C7.2,513.7,2.6,513.7,0.8,503.6z M326.7,654.6l-291-89.3c-10.1-2.8-15.7-13.8-12.9-23.9
l48.8-156.6c2.8-10.1,13.8-15.7,23.9-12.9l291,89.3c10.1,2.8,15.7,13.8,12.9,23.9l-48.8,156.6C347,651.9,336.9,657.4,326.7,654.6z
M584.5,442.8L390.3,623.3c-7.4,7.4-11,4.6-8.3-5.5L422.5,487c2.8-10.1,13.8-20.3,23.9-22.1l133.5-30.4
C590.1,431.8,591.9,436.4,584.5,442.8z M605.7,404.2L445.5,441c-10.1,2.8-20.3-3.7-23-13.8l-68.1-296.5c-2.8-10.1,3.7-20.3,13.8-23
l160.2-36.8c10.1-2.8,20.3,3.7,23,13.8l68.1,296.5C622.3,392.2,615.9,402.3,605.7,404.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
static/images/ollama.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
static/images/part-db.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
static/images/request.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
static/images/sonarr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
static/images/spoolman.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
static/images/tautulli.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -28,7 +28,9 @@
font-style: normal; font-style: normal;
} }
:root { :root,
.light {
color-scheme: light;
--bg: #f9f5f3; --bg: #f9f5f3;
--color: #1c1819; --color: #1c1819;
--highlight: #eaddd5; --highlight: #eaddd5;
@@ -37,9 +39,63 @@
--positive: #00d439; --positive: #00d439;
--negative: #ff5449; --negative: #ff5449;
--warning: #ffa312; --warning: #ffa312;
--pulse-positive: rgba(0, 212, 57, 0.7);
--pulse-negative: rgba(255, 84, 73, 0.7);
--pulse-warning: rgba(255, 163, 18, 0.7);
--border: 1px solid #eaddd5; --border: 1px solid #eaddd5;
--border-radius: 0.75rem; --border-radius: 0.75rem;
--card-bg: rgba(255, 255, 255, 0.7);
--key: #6e80d6;
--muted: #7a6d6e;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: light dark;
--bg: #121010;
--color: #f4eeeb;
--highlight: #2b2325;
--theme: #eaddd5;
--positive: #3ddc84;
--negative: #ff6b63;
--warning: #ffb74d;
--pulse-positive: rgba(61, 220, 132, 0.6);
--pulse-negative: rgba(255, 107, 99, 0.6);
--pulse-warning: rgba(255, 183, 77, 0.6);
--border: 1px solid #2b2325;
--key: #9fb3ff;
--muted: #b0b0b0;
--card-bg: #303037;
}
}
body.dark {
--bg: #121010;
--color: #f4eeeb;
--highlight: #2b2325;
--theme: #eaddd5;
--positive: #3ddc84;
--negative: #ff6b63;
--warning: #ffb74d;
--pulse-positive: rgba(61, 220, 132, 0.6);
--pulse-negative: rgba(255, 107, 99, 0.6);
--pulse-warning: rgba(255, 183, 77, 0.6);
--border: 1px solid #2b2325;
--key: #9fb3ff;
--muted: #b0b0b0;
--card-bg: #303037;
}
svg {
fill: var(--color);
} }
body { body {
@@ -51,6 +107,9 @@ body {
background-color: var(--bg); background-color: var(--bg);
color: var(--color); color: var(--color);
font-size: 14px; font-size: 14px;
transition:
background 0.3s ease,
color 0.3s ease;
} }
a, a,
@@ -96,6 +155,7 @@ button {
border: none; border: none;
position: relative; position: relative;
background: transparent; background: transparent;
color: var(--color);
height: 100%; height: 100%;
border-radius: 0.5rem; border-radius: 0.5rem;
display: inline-block; display: inline-block;
@@ -134,6 +194,21 @@ button.affirmative:hover span {
background-color: var(--highlight); background-color: var(--highlight);
} }
/* dark mode button overrides */
body.dark button span {
background-color: var(--theme);
color: var(--bg);
}
body.dark button.affirmative span {
color: var(--bg);
}
body.dark button.affirmative:hover span {
color: var(--color);
background-color: var(--highlight);
border-color: var(--bg);
}
button:disabled { button:disabled {
cursor: not-allowed; cursor: not-allowed;
} }
@@ -191,7 +266,7 @@ table tr:not(table tr:last-of-type) {
table tr:hover > td { table tr:hover > td {
background-color: var(--highlight); background-color: var(--highlight);
background-color: #f5ede9; background-color: var(--bg);
} }
table tr.link { table tr.link {
@@ -204,6 +279,14 @@ table tr.link {
border: var(--border); border: var(--border);
border-radius: var(--border-radius, 1rem); border-radius: var(--border-radius, 1rem);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
@media screen and (max-width: 750px) {
padding: 0.5rem;
}
}
body.dark .main-container {
background: var(--highlight) !important;
} }
.section-wrapper { .section-wrapper {

View File

@@ -44,14 +44,9 @@ COPY default.vcl.tmpl /etc/varnish/
COPY *.vcl /etc/varnish/ COPY *.vcl /etc/varnish/
COPY includes /etc/varnish/includes COPY includes /etc/varnish/includes
# Set variables for *.tmpl files # Create entrypoint script
ARG PROXY_HOST=$PROXY_HOST COPY docker-entrypoint.sh /usr/local/bin/
ARG IMAGE_HOST=$IMAGE_HOST RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Generate VCL
RUN gomplate -f /etc/varnish/default.vcl.tmpl -o /etc/varnish/default.vcl
RUN rm /etc/varnish/default.vcl.tmpl
EXPOSE 6081 EXPOSE 6081
CMD ["varnishd", "-F", "-f", "/etc/varnish/default.vcl", "-a", ":6081", "-s", "malloc,512m"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]

View File

@@ -46,17 +46,6 @@ sub vcl_recv {
unset req.http.Cookie; unset req.http.Cookie;
} }
// Svelte-kit needs to distinguish between it's own files and the Host header.
// The X-Forwarded-* headers below are to tell svelte-kit where it's local files are,
// and the Host header is included in the returned html & js referencing the external
// domain or proxy requested by client.
// https://svelte.dev/docs/kit/adapter-node#Environment-variables-ORIGIN-PROTOCOL_HEADER-HOST_HEADER-and-PORT_HEADER
sub vcl_backend_fetch {
set bereq.http.X-Forwarded-Host = "localhost";
set bereq.http.X-Forwarded-Port = "3000";
set bereq.http.X-Forwarded-Proto = "http";
}
sub vcl_synth { sub vcl_synth {
if (resp.status == 204) { if (resp.status == 204) {
set resp.http.Access-Control-Allow-Origin = "*"; set resp.http.Access-Control-Allow-Origin = "*";

View File

@@ -0,0 +1,8 @@
#!/bin/sh
set -e
# Generate VCL at runtime
gomplate -f /etc/varnish/default.vcl.tmpl -o /etc/varnish/default.vcl
# Execute startup CMD
exec varnishd -F -f /etc/varnish/default.vcl -a :6081 -s malloc,512m