24 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
685fe225f4 update kubernetes service port 2025-08-26 20:04:58 +02:00
0e8a99a277 update dependencies 2025-08-26 19:26:57 +02:00
76ac71a755 varnish on slimer OS 2025-08-23 23:32:21 +02:00
7251753df5 cleanup console & landing page 2025-08-23 22:33:28 +02:00
104 changed files with 3283 additions and 1953 deletions

View File

@@ -61,58 +61,6 @@ trigger:
depends_on:
- 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
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
depends_on:
- Build
---
kind: pipeline
type: docker
@@ -133,7 +81,7 @@ steps:
commands:
- mkdir -p /root/.kube
- 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
- 'curl -s
-H "X-Vault-Token: $VAULT_TOKEN"
@@ -181,8 +129,57 @@ depends_on:
volumes:
- name: kube-config
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
hmac: 01caa41521eac62356f6fc941cdd489dae8e2c4249bdb4e4dc1a32e101c639b7
hmac: b4b6a98b76fdf3cf297b46cf986a3d46f3d4050e623f2c769267181c7075a6ca
...

View File

@@ -1,5 +1,11 @@
DATABASE_URL=
TRAEFIK_URL=
HTTP_HEALTH_ENDPOINTS=
PROXMOX_URL=
PROXMOX_TOKEN=
HOMEASSISTANT_URL=
HOMEASSISTANT_TOKEN=
TRAEFIK_URL=
KUBERNETES_SERVICE_HOST=
KUBERNETES_SA_TOKEN=
KUBERNETES_CA_CERT_PATH=

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:
- backend:
service:
name: infra-map-service
name: varnish
port:
number: 80
path: /

View File

@@ -1,19 +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
kind: Service
metadata:
labels:
app: infra-map
name: infra-map-service
name: infra-map
namespace: ${NAMESPACE}
spec:
ports:
- port: 80
name: http-app
protocol: TCP
targetPort: 3000
selector:
app: infra-map
sessionAffinity: None
type: ClusterIP
status:
loadBalancer: {}

View File

@@ -1,20 +1,34 @@
# --- Stage 1: Compile svelte-kit project ---
FROM node:22-alpine3.20 AS builder
WORKDIR /app
# Copy source files
COPY src/ src
COPY static/ static
COPY package.json yarn.lock svelte.config.js tsconfig.json vite.config.ts ./
# Install dependencies
RUN yarn --frozen-lockfile
# Build project
ENV NODE_ENV=production
RUN yarn build
# --- Stage 2: Run project with node ---
FROM node:22-alpine3.20
# Copy compiled project files
WORKDIR /opt/infra-map
COPY --from=builder /app/build build
EXPOSE 3000
ENV NODE_ENV=production
# Install dependencies
RUN yarn install --frozen-lockfile
RUN yarn add @sveltejs/kit
EXPOSE 3000
ENV PORT=3000
CMD [ "node", "build/index.js" ]

View File

@@ -61,20 +61,50 @@ Follow hass documentation on generating a api token: https://developers.home-ass
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
yarn dev
```
## Building
To create a production version of your app:
To create a production version to be run from node:
```bash
npm run build
yarn build
```
You can preview the production build with `npm run preview`.
To preview either use vite to serve or execute node entrypoint:
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
```bash
yarn preview
```
or
```bash
node build/index.js
```
## Run using docker
The application is configure to be used either standalone or behind a webserver. To build the svelte-application docker image run:
> NB! Remember to configure .env, which is automatically picked up by docker-compose. Set and override for both containers in this file.
Svelte-kit application:
```bash
docker build -t infra-map .
```
Varnish cache:
```bash
cd varnish
docker build -t infra-varnish-cache .
```
Or both using docker compose:
```bash
docker compose up
```

View File

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

View File

@@ -31,8 +31,6 @@
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"sass-embedded": "^1.86.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"svelte": "^5.38.2",
"svelte-check": "^4.0.0",
"sveltekit-sse": "^0.13.16",

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

View File

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

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

@@ -1,6 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import User from '$lib/icons/user.svelte';
import { derived } from 'svelte/store';
// Create a derived store to extract breadcrumb data
@@ -9,14 +8,6 @@
return segments.map((segment, index) => {
let label = decodeURI(segment);
// if not uuid pattern, this is weird order of ops
/*
if (!segment.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) {
label = label.replace(/-/g, ' ')
}
*/
return {
label,
path: '/' + segments.slice(0, index + 1).join('/')
@@ -40,10 +31,6 @@
<a href={crumb.path}>{crumb.label}</a>
{/each}
</div>
<div class="right">
<User />
</div>
</div>
<style lang="scss">
@@ -59,7 +46,7 @@
background: var(--theme);
padding: 0 1rem;
border-radius: 6px;
color: white;
color: var(--bg);
margin: 1rem 0.5rem 0 0.5rem;
font-weight: 400;
font-size: 1rem;
@@ -79,7 +66,7 @@
font-size: 1.5rem;
padding: 0;
font-weight: 300;
color: white !important;
color: var(--bg) !important;
}
img {

View File

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

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

View File

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

View File

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

View File

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

View File

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

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 {
display: flex;
flex-direction: column;

View File

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

View File

@@ -1,13 +1,7 @@
<script lang="ts">
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();
@@ -60,7 +54,7 @@
h2,
.link,
.title {
transition: all 0.2s ease-in-out;
transition: all 0.18s ease-in-out;
}
.title {
@@ -90,7 +84,8 @@
.image {
height: 8rem;
width: 100%;
margin: 1.2rem 0;
width: 8rem;
margin: 1.2rem auto;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
@@ -105,6 +100,7 @@
left: calc(100% - 2rem);
top: 0;
opacity: 0;
fill: var(--color);
}
@media screen and (max-width: 750px) {
@@ -130,7 +126,6 @@
opacity: 1;
left: calc(100% - 1rem);
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>
<style lang="scss">
button {
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
margin: 0;
letter-spacing: 0.2px;
&.selected {
opacity: 1;
letter-spacing: unset;
border-bottom-color: var(--color) !important;
font-weight: 600 !important;
}
}
.tab {
&:not(&:first-of-type) {
margin-left: 0.75rem;
@@ -40,10 +24,21 @@
font-size: 1.1rem;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
opacity: 0.7;
background: none;
opacity: 0.6;
margin: 0;
padding-bottom: 0.3rem;
transition: 0.3s ease-in-out all;
border-radius: 0;
border: none;
border-bottom: 2px solid transparent;
letter-spacing: 0.2px;
&.selected {
opacity: 1;
letter-spacing: unset;
border-bottom-color: var(--color) !important;
font-weight: 600 !important;
}
}
}
</style>

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
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="768"
height="768"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<g id="icomoon-ignore"> </g>

Before

Width:  |  Height:  |  Size: 816 B

After

Width:  |  Height:  |  Size: 818 B

View File

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

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 805 B

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
transform="translate(0.000000,108.000000) scale(0.100000,-0.100000)"
fill="#000000"
stroke="none"
>
<path

Before

Width:  |  Height:  |  Size: 896 B

After

Width:  |  Height:  |  Size: 879 B

View File

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

Before

Width:  |  Height:  |  Size: 760 B

After

Width:  |  Height:  |  Size: 699 B

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

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
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="768"
height="768"
width="100%"
height="100%"
viewBox="0 0 768 768"
>
<g id="icomoon-ignore"> </g>

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 649 B

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

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
transform="translate(0.000000,122.000000) scale(0.100000,-0.100000)"
fill="#000000"
stroke="none"
>
<path

Before

Width:  |  Height:  |  Size: 746 B

After

Width:  |  Height:  |  Size: 729 B

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
transform="translate(0.000000,182.000000) scale(0.100000,-0.100000)"
fill="#000000"
stroke="none"
>
<path

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

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

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';
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;
}
import { getDb } from '../database';
export async function getAllFilament(): Promise<Array<Filament>> {
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();
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);
} 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 }> {
try {
const { nodes, cluster } = await getClusterInfo();
const infoP = Promise.all(nodes.map((node: Node) => fetchNodeInfo(node)));
const vmsP = Promise.all(nodes.map((node: Node) => fetchNodeVMs(node)));
const lxcsP = Promise.all(nodes.map((node: Node) => fetchNodeLXCs(node)));
const infoBulk = Promise.all(nodes.map((node: Node) => fetchNodeInfo(node)));
const vmsBulk = Promise.all(nodes.map((node: Node) => fetchNodeVMs(node)));
const lxcsBulk = Promise.all(nodes.map((node: Node) => fetchNodeLXCs(node)));
const [info, vms, lxcs] = await Promise.all([infoP, vmsP, lxcsP]);
const [info, vms, lxcs] = await Promise.all([infoBulk, vmsBulk, lxcsBulk]);
return {
cluster,

175
src/lib/styles/card.scss Normal file
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">
import Header from '$lib/components/Header.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import DarkmodeToggle from '$lib/components/DarkmodeToggle.svelte';
import GlobalSearch from '$lib/components/GlobalSearch.svelte';
</script>
<div class="page">
@@ -13,6 +15,9 @@
<slot></slot>
</main>
</div>
<DarkmodeToggle />
<GlobalSearch />
</div>
<style lang="scss">

View File

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

View File

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

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

View File

@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types';
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';
interface PrinterState {

View File

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

View File

@@ -1,5 +1,5 @@
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]) => {
let { id } = params;

View File

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

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">
import PageHeader from '$lib/components/PageHeader.svelte';
import Dialog from '$lib/components/Dialog.svelte';
import Section from '$lib/components/Section.svelte';
import FormSite from '$lib/components/forms/FormSite.svelte';
import ThumbnailButton from '$lib/components/ThumbnailButton.svelte';
import type { Site } from '$lib/interfaces/site.ts';
import { onMount } from 'svelte';
interface Site {
title: string;
image: string;
link: string;
background?: string;
color?: string;
}
let { data }: { data: { site: Site } } = $props();
let open = $state(false);
const sites: Array<Site> = [
{
title: 'Grafana',
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'
}
];
const { sites } = data;
onMount(() => window.elements = sites)
</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">
{#each sites as site}
{#each sites as site (site)}
<ThumbnailButton
title={site.title}
title={site.name}
image={site.image}
background={site.background}
color={site.color}
@@ -106,27 +31,15 @@
{/each}
</div>
<div class="section-wrapper full-width">
<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="IP restrictions"
description="Restrict or block access to your application based on specific IP addresses or CIDR blocks."
/>
<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>
{#if open}
<Dialog
close={() => (open = false)}
title="Add new site"
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
>
<FormSite on:close={() => (open = false)} />
</Dialog>
{/if}
<style lang="scss">
.section-wrapper {
@@ -149,4 +62,10 @@
margin-top: 4rem;
}
}
:global(button.add-site-btn) {
font-size: 1.2rem;
float: right;
height: 2.5rem;
}
</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;
}
:root {
:root,
.light {
color-scheme: light;
--bg: #f9f5f3;
--color: #1c1819;
--highlight: #eaddd5;
@@ -37,9 +39,63 @@
--positive: #00d439;
--negative: #ff5449;
--warning: #ffa312;
--pulse-positive: rgba(0, 212, 57, 0.7);
--pulse-negative: rgba(255, 84, 73, 0.7);
--pulse-warning: rgba(255, 163, 18, 0.7);
--border: 1px solid #eaddd5;
--border-radius: 0.75rem;
--card-bg: rgba(255, 255, 255, 0.7);
--key: #6e80d6;
--muted: #7a6d6e;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: light dark;
--bg: #121010;
--color: #f4eeeb;
--highlight: #2b2325;
--theme: #eaddd5;
--positive: #3ddc84;
--negative: #ff6b63;
--warning: #ffb74d;
--pulse-positive: rgba(61, 220, 132, 0.6);
--pulse-negative: rgba(255, 107, 99, 0.6);
--pulse-warning: rgba(255, 183, 77, 0.6);
--border: 1px solid #2b2325;
--key: #9fb3ff;
--muted: #b0b0b0;
--card-bg: #303037;
}
}
body.dark {
--bg: #121010;
--color: #f4eeeb;
--highlight: #2b2325;
--theme: #eaddd5;
--positive: #3ddc84;
--negative: #ff6b63;
--warning: #ffb74d;
--pulse-positive: rgba(61, 220, 132, 0.6);
--pulse-negative: rgba(255, 107, 99, 0.6);
--pulse-warning: rgba(255, 183, 77, 0.6);
--border: 1px solid #2b2325;
--key: #9fb3ff;
--muted: #b0b0b0;
--card-bg: #303037;
}
svg {
fill: var(--color);
}
body {
@@ -51,6 +107,9 @@ body {
background-color: var(--bg);
color: var(--color);
font-size: 14px;
transition:
background 0.3s ease,
color 0.3s ease;
}
a,
@@ -96,6 +155,7 @@ button {
border: none;
position: relative;
background: transparent;
color: var(--color);
height: 100%;
border-radius: 0.5rem;
display: inline-block;
@@ -134,6 +194,21 @@ button.affirmative:hover span {
background-color: var(--highlight);
}
/* dark mode button overrides */
body.dark button span {
background-color: var(--theme);
color: var(--bg);
}
body.dark button.affirmative span {
color: var(--bg);
}
body.dark button.affirmative:hover span {
color: var(--color);
background-color: var(--highlight);
border-color: var(--bg);
}
button:disabled {
cursor: not-allowed;
}
@@ -191,7 +266,7 @@ table tr:not(table tr:last-of-type) {
table tr:hover > td {
background-color: var(--highlight);
background-color: #f5ede9;
background-color: var(--bg);
}
table tr.link {
@@ -204,6 +279,14 @@ table tr.link {
border: var(--border);
border-radius: var(--border-radius, 1rem);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
@media screen and (max-width: 750px) {
padding: 0.5rem;
}
}
body.dark .main-container {
background: var(--highlight) !important;
}
.section-wrapper {

Some files were not shown because too many files have changed in this diff Show More