27 Commits

Author SHA1 Message Date
7d1f536af5 proxy through varnish
sets up varnish cache server with:
- custom cache per content type
- gzip
- configure app & hass backends
- varnish debug headers & X-Cache verbose cache header

also updates docker-compose, varnish/Dockerfile & drone.
2025-08-21 23:41:26 +02:00
8c79806318 filament uses postgres 2025-08-19 00:00:00 +02:00
db03c8a375 install from lockfile & remove yarn from runtime 2025-08-18 21:04:17 +02:00
a698a88983 update node packages 2025-08-18 21:03:53 +02:00
31320f3796 publish custom varnish image to ghcr.io 2025-08-18 19:11:55 +02:00
b640426064 proxy images from HASS through varnish 2025-08-18 18:32:31 +02:00
f307bcb79b search case-insensitive & sticky position search box 2025-08-18 18:16:31 +02:00
1e88ddc50d updates printer image path 2025-08-18 18:15:51 +02:00
5905f8f810 affirmative button on hover has better contrast background 2025-08-18 18:15:26 +02:00
6af6def556 moved printer.png to images/ 2025-08-18 18:14:54 +02:00
ed742d9ab8 favicons 2025-08-18 18:14:43 +02:00
9f55fe687d center align navigation icon to text 2025-08-18 18:14:26 +02:00
0fbade138d display idle/failed pods with dashed border 2025-08-18 18:14:06 +02:00
e717f85c52 multiple topology modes to toggle between 2025-08-18 00:43:49 +02:00
d45693bc9b enables experimental remote functions 2025-08-17 22:57:49 +02:00
93ab8ff1c3 build opts: external force-graph dep 2025-08-17 22:21:59 +02:00
66f1603eeb linting 2025-08-17 22:13:40 +02:00
6fa1beac99 zigbee device graph visualization 2025-08-17 22:11:47 +02:00
c94a2bf5d9 dynamic sidebar elements based on routes on disk 2025-08-17 22:11:26 +02:00
21581bd9e5 server summary 2025-08-14 09:49:49 +02:00
ae7ccc9081 theme color a bit more brown 2025-08-14 09:44:23 +02:00
6e398f72da mobile layout server, sites & home route pages 2025-08-14 00:51:29 +02:00
a105fc44a8 theme color 2025-08-14 00:18:07 +02:00
2c1ad22bf7 update kube secrets 2025-08-14 00:05:14 +02:00
81ddc796c2 more sites w/ images 2025-08-13 22:14:00 +02:00
6d4da254cd mobile sidebar - collapsable 2025-08-13 22:13:46 +02:00
ea9cdb7692 working nice. docker uses bun 2025-08-13 00:45:15 +02:00
118 changed files with 14726 additions and 1303 deletions

View File

@@ -33,12 +33,13 @@ platform:
arch: amd64
steps:
- name: Publish to ghcr
- name: Publish app to ghcr
image: plugins/docker
settings:
registry: ghcr.io
repo: ghcr.io/kevinmidboe/${DRONE_REPO_NAME}
dockerfile: Dockerfile
compress: true
username:
from_secret: GITHUB_USERNAME
password:
@@ -55,10 +56,63 @@ trigger:
- pull_request
branch:
- main
- update
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
@@ -79,6 +133,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 "NAMESPACE=${DRONE_REPO_NAME}" >> /root/.kube/.env
- 'curl -s
-H "X-Vault-Token: $VAULT_TOKEN"
@@ -117,6 +172,7 @@ trigger:
- pull_request
branch:
- main
- update
depends_on:
- Build
@@ -127,6 +183,6 @@ volumes:
temp: {}
---
kind: signature
hmac: bea117f5e4b51c4fc215ae86962a8cfb24993d9e9b7db3498ab5940b10c70d69
hmac: 01caa41521eac62356f6fc941cdd489dae8e2c4249bdb4e4dc1a32e101c639b7
...

View File

@@ -11,3 +11,7 @@ data:
HOMEASSISTANT_URL: ${HOMEASSISTANT_URL}
HOMEASSISTANT_TOKEN: ${HOMEASSISTANT_TOKEN}
TRAEFIK_URL: ${TRAEFIK_URL}
HTTP_HEALTH_ENDPOINTS: ${HTTP_HEALTH_ENDPOINTS}
KUBERNETES_SERVICE_HOST: ${KUBERNETES_SERVICE_HOST}
KUBERNETES_SA_TOKEN: ${KUBERNETES_SA_TOKEN}
DATABASE_URL: ${DATABASE_URL}

View File

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

View File

@@ -21,32 +21,25 @@ spec:
- image: ${IMAGE}
imagePullPolicy: IfNotPresent
name: infra-map
env:
- name: HOMEASSISTANT_TOKEN
valueFrom:
secretKeyRef:
envFrom:
- secretRef:
name: secret-env-values
key: HOMEASSISTANT_TOKEN
- name: HOMEASSISTANT_URL
valueFrom:
secretKeyRef:
name: secret-env-values
key: HOMEASSISTANT_URL
- name: PROXMOX_TOKEN
valueFrom:
secretKeyRef:
name: secret-env-values
key: PROXMOX_TOKEN
- name: PROXMOX_URL
valueFrom:
secretKeyRef:
name: secret-env-values
key: PROXMOX_URL
- name: TRAEFIK_URL
valueFrom:
secretKeyRef:
name: secret-env-values
key: TRAEFIK_URL
resources:
limits:
cpu: 900m
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
@@ -57,3 +50,7 @@ spec:
restartPolicy: Always
imagePullSecrets:
- name: ghcr-login-secret
volumes:
- name: varnish-vcl
configMap:
name: varnish-vcl

View File

@@ -6,7 +6,7 @@ COPY src/ src
COPY static/ static
COPY package.json yarn.lock svelte.config.js tsconfig.json vite.config.ts ./
RUN yarn
RUN yarn --frozen-lockfile
RUN yarn build
FROM node:22-alpine3.20
@@ -14,10 +14,7 @@ FROM node:22-alpine3.20
WORKDIR /opt/infra-map
COPY --from=builder /app/build build
COPY package.json .
RUN yarn --production
EXPOSE 3000
ENV NODE_ENV=production
CMD [ "node", "build" ]
CMD [ "node", "build/index.js" ]

View File

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

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
version: '3.8'
services:
app:
build: .
container_name: infra-map
ports:
- '3000:3000' # svelte-kit preview HTTP
varnish:
build: varnish
container_name: varnish-cache
ports:
- '6081:6081' # Varnish HTTP
environment:
- VARNISH_LISTEN_PORT=6081
command: >
varnishd
-F
-f /etc/varnish/default.vcl
-s malloc,256m
-a :6081

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"build": "svelte-kit sync && vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -16,24 +16,29 @@
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@kubernetes/client-node": "^1.1.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@sveltejs/kit": "^2.27.0",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"@zerodevx/svelte-json-view": "^1.0.11",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"force-graph": "^1.50.1",
"pg": "^8.16.3",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"sass-embedded": "^1.86.0",
"svelte": "^5.0.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"svelte": "^5.38.2",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"sveltekit-sse": "^0.13.16",
"typescript": "^5.9.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.0"
"vite": "^7.1.2"
},
"dependencies": {
"@kubernetes/client-node": "^1.1.0",
"@microsoft/fetch-event-source": "^2.0.1",
"sveltekit-sse": "^0.13.16"
}
"dependencies": {}
}

View File

@@ -5,6 +5,14 @@
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="stylesheet" href="%sveltekit.assets%/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#32292B" />
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="infra" />
<link rel="manifest" href="/favicon/site.webmanifest" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { clickOutside } from '$lib/utils/mouseEvents';
export let title: string;
export let description: string | null = null;
const dispatch = createEventDispatcher();
const close = () => dispatch('close');
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
function handleClick(event: MouseEvent) {
const element = document.getElementsByClassName('dialog')?.[0]?.children[0];
if (clickOutside(event, element) === false) return;
close();
}
onMount(() => {
window.addEventListener('keydown', handleKeydown);
setTimeout(() => window.addEventListener('click', handleClick), 100);
});
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('click', handleClick);
});
</script>
<div
role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
class="dialog"
>
<div tabindex="-1" id="dialog-title" class="title">
<header>
<button on:click={close} aria-disabled="false" aria-label="Close" type="button" tabindex="0"
><svg viewBox="0 0 24 24" aria-hidden="true" tabindex="-1" height="100%" width="100%"
><path
d="M6.909 5.636a.9.9 0 1 0-1.273 1.273l5.091 5.09-5.091 5.092a.9.9 0 0 0 1.273 1.273L12 13.273l5.091 5.09a.9.9 0 1 0 1.273-1.272L13.273 12l5.09-5.091a.9.9 0 1 0-1.272-1.273L12 10.727z"
></path></svg
>
</button>
<h5>{title}</h5>
</header>
<main>
<div id="dialog-description">
{#if description}
{@html description}
{/if}
</div>
<!--
<div class="alerts">
<Success>There are no applications created yet.</Success>
<Warning>There are no applications created yet.</Warning>
<Error>There are no applications created yet.</Error>
</div>
-->
<div>
<slot></slot>
</div>
</main>
</div>
</div>
<style lang="scss">
:global(.alerts > *) {
margin-bottom: 0.5rem;
}
.dialog {
display: flex;
align-items: flex-start;
justify-content: center;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2006;
width: 100%;
pointer-events: all;
background-color: #7d666580;
transition:
opacity 0.4s ease,
visibility 0.4s ease;
visibility: visible;
opacity: 1;
align-items: center;
> div {
max-width: 880px;
max-width: unset;
}
}
.title {
--padding: 1rem;
position: relative;
background-color: #ffffff;
background-clip: padding-box;
border-radius: 12px;
display: flex;
flex-direction: column;
border: 0;
opacity: 0;
transform: translate(0, 2rem);
transition:
transform 0.4s ease,
opacity 0.4s ease;
box-shadow:
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
pointer-events: auto;
max-height: 90vh;
padding: var(--padding);
width: calc(880px - calc(--padding * 2));
z-index: 2008;
max-width: 100%;
visibility: visible;
opacity: 1;
transform: translate(0, 0);
header {
padding: 24px;
padding: 24px 16px;
flex: 0 0 auto;
flex-direction: row-reverse;
display: flex;
align-items: center;
button {
flex: unset;
border: none;
padding: 0;
position: relative;
background: transparent;
height: 1.5rem;
width: 1.5rem;
border-radius: 8px;
display: inline-block;
text-decoration: none;
fill: orange;
fill: #0a0a0a;
}
h5 {
margin: 0 auto 0 0;
font-size: 1.4rem;
font-weight: 400;
}
}
main #dialog-description {
padding-bottom: 1rem;
}
main > * {
padding-top: 1rem;
padding-top: 0rem;
}
}
</style>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import User from '$lib/icons/user.svelte';
import { derived } from 'svelte/store';
// Create a derived store to extract breadcrumb data
@@ -26,8 +27,10 @@
<div class="header">
<div class="left">
<!-- <img src="/logo.png" /> -->
<h1>schleppe.cloud</h1>
<a href="/">
<!-- <img src="/logo.png" /> -->
<h1>schleppe.cloud</h1>
</a>
</div>
<div class="middle crumbs">
@@ -39,7 +42,7 @@
</div>
<div class="right">
<span>User profile</span>
<User />
</div>
</div>
@@ -50,10 +53,10 @@
left: 0;
display: grid;
grid-template-columns: 240px 1fr auto;
grid-template-columns: 200px 1fr auto;
grid-template-areas: 'logoSection siteAndEnvironment profileAndHelp';
align-items: center;
background: #1c1819;
background: var(--theme);
padding: 0 1rem;
border-radius: 6px;
color: white;
@@ -62,7 +65,7 @@
font-size: 1rem;
z-index: 100;
&::after {
&::before {
content: '';
position: absolute;
width: 100%;
@@ -76,6 +79,7 @@
font-size: 1.5rem;
padding: 0;
font-weight: 300;
color: white !important;
}
img {
@@ -100,10 +104,11 @@
}
.crumbs {
margin-left: 0.6rem;
margin-left: 2rem;
li {
display: block;
cursor: pointer;
}
.seperator {
@@ -111,5 +116,20 @@
padding: 0 0.75rem;
}
}
@media screen and (max-width: 750px) {
top: -0.25rem;
overflow: scroll;
.crumbs {
margin-left: 0;
}
}
}
:global(.right svg) {
height: 1.5rem;
width: 1.5rem;
fill: white;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
import Network from '$lib/icons/network.svelte';
import Layers from '$lib/icons/layers.svelte';
import Clock from '$lib/icons/clock.svelte';
import Sync from '$lib/icons/sync.svelte';
import { formatDuration } from '$lib/utils/conversion';
import { onMount } from 'svelte';
@@ -33,12 +34,17 @@
// set uptime
let uptime = writable(new Date().getTime() - new Date(status?.startTime || 0).getTime());
function idlePhase(phase: string | undefined) {
const phases = ['Failed', 'Succeeded'];
return phases.includes(phase || '');
}
onMount(() => {
setInterval(() => uptime.update((n) => n + 1000), 1000);
});
</script>
<div class="card">
<div class={`card ${idlePhase(status?.phase) && 'not-running'}`}>
<div class="header">
<div class="icon"><Layers /></div>
<span class="name">{name}</span>
@@ -67,6 +73,12 @@
</div>
<span>{i + 1} of {replicas}</span>
<div class="title">
<Sync />
<span>Restarts</span>
</div>
<span>{status?.containerStatuses?.[0].restartCount}</span>
<div class="title">
<Connection />
<span>Running on Node</span>
@@ -124,6 +136,11 @@
);
pointer-events: all;
cursor: auto;
&.not-running {
border: 2px dashed var(--theme);
opacity: 0.6;
}
}
.header {

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { formatBytes } from '$lib/utils/conversion';
import type { Node } from '$lib/interfaces/proxmox';
export let nodes: Node[];
const totalCpu = nodes.map((n) => n.info.cpuinfo.cpus).reduce((a, b) => a + b, 0);
const totalMem = nodes.map((n) => n.info.memory.total).reduce((a, b) => a + b, 0);
const vmState = {
total: nodes.map((n) => n.vms.filter((v) => v?.template !== 1)).flat(2).length,
running: nodes
.map((n) => n.vms.filter((v) => v?.template !== 1 && v.status === 'running'))
.flat(2).length
};
const lxcState = {
total: nodes.map((n) => n.lxcs.filter((l) => l?.template !== 1)).flat(2).length,
running: nodes
.map((n) => n.lxcs.filter((l) => l?.template !== 1 && l.status === 'running'))
.flat(2).length
};
</script>
<div class="main-container">
<span>CPUs: <b>{totalCpu}</b></span>
<span>Memory: <b>{formatBytes(totalMem)}</b></span>
<span>VMs: <b>{vmState.running}/{vmState.total}</b></span>
<span>LXCs: <b>{lxcState.running}/{lxcState.total}</b></span>
<span>Nodes: <b>{nodes.length}</b></span>
</div>
<style lang="scss">
.main-container {
margin-bottom: 2rem;
span {
display: inline-block;
&:not(:last-of-type) {
margin-right: 0.75rem;
}
}
b {
font-family: 'Reckless Neue';
font-size: 1.3rem;
color: var(--theme);
}
}
</style>

View File

@@ -1,49 +1,49 @@
<script lang="ts">
import { page } from '$app/stores';
import { derived } from 'svelte/store';
import { allRoutes } from '$lib/remote/filesystem.remote.ts';
const pages = [
{
name: 'Home',
path: '/'
},
{
name: 'Sites',
path: '/sites'
},
{
name: 'Servers',
path: '/servers'
},
{
name: 'Printer',
path: '/printer'
},
{
name: 'Network',
path: '/network'
},
{
name: 'Cluster',
path: '/cluster'
},
{
name: 'Health',
path: '/health'
}
];
let mobileNavOpen = $state(false);
let pages = $state([]);
async function resolvePages() {
pages = await allRoutes();
}
resolvePages();
const activePage = derived(page, ($page) => $page.url.pathname);
const toggle = () => {
mobileNavOpen = !mobileNavOpen;
// nav opens
if (mobileNavOpen) {
const index = pages.findIndex((page) => $activePage === page.path);
const el: HTMLElement = document.getElementsByTagName('nav')[0].getElementsByTagName('a')[
index
];
if (!el) return;
setTimeout(() => {
el?.scrollIntoViewIfNeeded();
}, 300);
}
};
</script>
<div class="nav-wrapper">
<div class={`nav-wrapper ${mobileNavOpen ? 'open' : ''}`}>
<nav>
{#each pages as page, i (page.name)}
{#if i === 0}
<a class={$activePage === page.path ? 'highlight' : ''} href={page.path}>{page.name}</a>
<a
class={$activePage === page.path ? 'highlight' : ''}
on:click={() => (mobileNavOpen = false)}
href={page.path}>{page.name}</a
>
{:else}
<a
class={`${$activePage !== page.path && $activePage.startsWith(page.path) ? 'child' : ''} ${$activePage.startsWith(page.path) ? 'highlight' : ''}`}
on:click={() => (mobileNavOpen = false)}
href={page.path}>{page.name}</a
>
{/if}
@@ -51,25 +51,132 @@
</nav>
</div>
<div id="mobile-nav-toggle" class={mobileNavOpen ? 'open' : ''} on:click={toggle}>
<span></span>
<span></span>
<span></span>
</div>
<style lang="scss">
#mobile-nav-toggle {
--size: 3rem;
--padding: 2rem;
position: fixed;
z-index: 99;
width: var(--size);
height: var(--size);
border-radius: 50%;
right: 1rem;
bottom: var(--padding);
background-color: var(--color);
display: flex;
flex-direction: column;
gap: 0.4rem;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.2s ease-in-out;
@media screen and (min-width: 750px) {
display: none;
}
span {
width: 50%;
border-radius: 4px;
height: 3px;
background-color: var(--highlight);
transition: all 0.2s ease-in-out;
opacity: 100%;
}
&.open,
&:hover {
height: calc(var(--size) + 4px);
width: calc(var(--size) + 4px);
margin-bottom: -2px;
margin-right: -2px;
}
&.open {
span {
margin-left: 10px;
}
span:nth-child(1) {
transform: rotate(45deg);
transform-origin: top left;
}
span:nth-child(2) {
opacity: 0%;
height: 1px;
}
span:nth-child(3) {
transform: rotate(-45deg);
transform-origin: bottom left;
}
}
}
.nav-wrapper {
--nav-width: 240px;
top: 72px;
left: 0;
right: 0;
min-width: var(--nav-width);
margin-right: 1rem;
transition: all 0.4s ease-in-out;
z-index: 99;
border-radius: var(--border-radius);
border-bottom-right-radius: 0;
border-top-right-radius: 0;
max-height: 66vh;
height: fit-content;
background-color: var(--bg);
@media screen and (max-width: 700px) {
--nav-width: 100px;
@media screen and (max-width: 1460px) {
--nav-width: 220px;
margin-left: 0.5rem;
}
@media screen and (max-width: 1200px) {
--nav-width: 140px;
margin-left: 0.5rem;
margin-right: 0;
}
/* mobile */
@media screen and (max-width: 750px) {
position: fixed;
right: -100%;
padding-top: 0;
padding: 0.51rem;
top: calc(72px + 1rem);
&.open {
right: 0.5rem;
right: -1rem;
background-color: var(--color);
overflow-y: scroll;
nav a {
color: white;
&:hover,
&.highlight {
color: black;
}
}
}
}
}
nav {
display: flex;
flex-direction: column;
position: fixed;
/* position: fixed; */
width: var(--nav-width);
gap: 4px;
margin-top: 1rem;

View File

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

View File

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

View File

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Input from '$lib/components/Input.svelte';
import ColorInput from '$lib/components/ColorInput.svelte';
import Dropdown from '$lib/components/Dropdown.svelte';
import Flower from '$lib/icons/flower.svelte';
import Weight from '$lib/icons/weight.svelte';
import Link from '$lib/icons/link.svelte';
import PencilRuler from '$lib/icons/pencil-ruler.svelte';
const dispatch = createEventDispatcher();
const close = () => dispatch('close');
const materialOptions = ['PLA Matte', 'PLA Basic', 'PLA-CF', 'PET-G'];
const weightOptions = ['0.5 kg', '1 kg', '2 kg'];
let process = $state('');
let port = $state('');
</script>
<form method="POST" action="/printer/filament">
<div class="wrapper">
<ColorInput label="Hex" required />
<Input label="Color name" icon={Flower} placeholder="Infinity orange" required />
<Dropdown
placeholder="Plastic material name"
label="Material"
required={true}
icon={PencilRuler}
options={materialOptions}
/>
<Dropdown
placeholder="Spool weight"
label="Weight"
icon={Weight}
required={true}
options={weightOptions}
/>
<Input label="Link" icon={Link} placeholder="https://store.shop/item" required />
</div>
<footer>
<button on:click={close} aria-disabled="false" type="button" tabindex="0"
><span tabindex="-1">Cancel</span></button
>
<button class="affirmative" type="submit" tabindex="-1">
<span tabindex="-1">Add connection</span>
</button>
</footer>
</form>
<style lang="scss">
form {
.wrapper {
display: flex;
flex-direction: column;
}
footer {
padding: 0.75rem 1.5rem;
max-width: 100%;
height: 2.5rem;
width: auto;
display: flex;
justify-content: flex-end;
flex: 0 0 auto;
gap: 1rem;
flex-wrap: wrap;
button {
flex: unset;
span {
font-size: 0.8rem;
}
}
}
}
:global(form .wrapper div) {
margin-bottom: 0.5rem;
}
</style>

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 699 B

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 367 B

View File

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

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.8 KiB

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

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

Before

Width:  |  Height:  |  Size: 852 B

After

Width:  |  Height:  |  Size: 854 B

View File

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

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

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

After

Width:  |  Height:  |  Size: 306 B

View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

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

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

After

Width:  |  Height:  |  Size: 802 B

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

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

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

After

Width:  |  Height:  |  Size: 755 B

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
import { prerender } from '$app/server';
export const allRoutes = prerender(() => {
const modules = import.meta.glob('/src/routes/**/+page.svelte');
const routes = Object.keys(modules).map((path) => {
// Remove '/src/routes' prefix and '+page.svelte' suffix
let route = path.replace('/src/routes', '').replace('/+page.svelte', '');
// Handle the root route
route = route.toString().split('/')[1];
return route;
});
const allRoute = [...new Set(routes)].map((r: string) => {
return {
name: r?.length > 1 ? r[0].toUpperCase() + r.slice(1, r.length) : r,
path: '/' + r
};
});
return [{ name: 'Home', path: '/' }, ...allRoute].filter((r) => r.name);
});

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

@@ -0,0 +1,171 @@
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;
}
export async function getAllFilament(): Promise<Array<Filament>> {
const pool = await getDb();
const result = await pool.query('SELECT * FROM filament');
return result.rows || [];
}
export async function getFilamentByColor(name: string) {
const pool = await getDb();
const result = await pool.query('SELECT * FROM filament WHERE LOWER(color) = LOWER($1) LIMIT 1', [
name
]);
return result.rows[0] || undefined;
}
export async function addFilament(
hex: string,
color: string,
material: string,
weight: number,
link: string
) {
const timestamp = Math.floor(new Date().getTime() / 1000);
const pool = await getDb();
const result = await pool.query(
`INSERT INTO filament (hex, color, material, weight, link, added, updated)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[hex, color, material, weight, link, timestamp, timestamp]
);
return { id: result.rows[0].id };
}
export async function updateFilament({
id,
hex,
color,
material,
weight,
link
}: {
id: number;
hex?: string;
color?: string;
material?: string;
weight?: number;
link?: string;
}) {
const pool = await getDb();
// Dynamically build query based on provided fields
const fields = [];
const values = [];
let i = 1;
if (hex !== undefined) {
fields.push(`hex = $${i++}`);
values.push(hex);
}
if (color !== undefined) {
fields.push(`color = $${i++}`);
values.push(color);
}
if (material !== undefined) {
fields.push(`material = $${i++}`);
values.push(material);
}
if (weight !== undefined) {
fields.push(`weight = $${i++}`);
values.push(weight);
}
if (link !== undefined) {
fields.push(`link = $${i++}`);
values.push(link);
}
if (fields.length === 0) return; // nothing to update
values.push(id);
const query = `UPDATE filament SET ${fields.join(', ')}, updated = EXTRACT(EPOCH FROM NOW())::INT WHERE id = $${i}`;
await pool.query(query, values);
}

View File

@@ -2,84 +2,84 @@ import type { Filament } from '$lib/interfaces/printer';
const filament: Filament[] = [
{
Hex: '#DD4344',
Color: 'Scarlet Red',
Material: 'PLA Matte',
Weight: '1kg',
Count: 2,
Link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742848731'
hex: '#DD4344',
color: 'Scarlet Red',
material: 'PLA Matte',
weight: 1,
count: 2,
link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742848731'
},
{
Hex: '#61C57F',
Color: 'Grass Green',
Material: 'PLA Matte',
Weight: '1kg',
Count: 2,
Link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742783195'
hex: '#61C57F',
color: 'Grass Green',
material: 'PLA Matte',
weight: 1,
count: 2,
link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742783195'
},
{
Hex: '#F7DA5A',
Color: 'Lemon Yellow',
Material: 'PLA Matte',
Weight: '1kg',
Count: 2,
Link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742717659'
hex: '#F7DA5A',
color: 'Lemon Yellow',
material: 'PLA Matte',
weight: 1,
count: 2,
link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742717659'
},
{
Hex: '#E8DBB7',
Color: 'Desert Tan',
Material: 'PLA Matte',
Weight: '1kg',
Count: 1,
Link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=48612736401756'
hex: '#E8DBB7',
color: 'Desert Tan',
material: 'PLA Matte',
weight: 1,
count: 1,
link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=48612736401756'
},
{
Hex: "url('https://www.transparenttextures.com/patterns/asfalt-dark.png'",
Color: 'White Marble',
Material: 'PLA Marble',
Weight: '1kg',
Count: 1,
Link: 'https://eu.store.bambulab.com/en-no/products/pla-marble?variant=43964050964699'
hex: "url('https://www.transparenttextures.com/patterns/asfalt-dark.png'",
color: 'White Marble',
material: 'PLA Marble',
weight: 1,
count: 1,
link: 'https://eu.store.bambulab.com/en-no/products/pla-marble?variant=43964050964699'
},
{
Hex: '#0078C0',
Color: 'Marine Blue',
Material: 'PLA Matte',
Weight: '1kg',
Count: 1,
Link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996751073499'
hex: '#0078C0',
color: 'Marine Blue',
material: 'PLA Matte',
weight: 1,
count: 1,
link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996751073499'
},
{
Hex: '#000000',
Color: 'Charcoal',
Material: 'PLA Matte',
Weight: '1kg',
Count: 2,
Link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742750427'
hex: '#000000',
color: 'Charcoal',
material: 'PLA Matte',
weight: 1,
count: 2,
link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742750427'
},
{
Hex: '#ffffff',
Color: 'Ivory White',
Material: 'PLA Matte',
Weight: '1kg',
Count: 2,
Link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742586587'
hex: '#ffffff',
color: 'Ivory White',
material: 'PLA Matte',
weight: 1,
count: 2,
link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742586587'
},
{
Hex: '#E8AFCE',
Color: 'Sakura Pink',
Material: 'PLA Matte',
Weight: '1kg',
Count: 1,
Link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742684891'
hex: '#E8AFCE',
color: 'Sakura Pink',
material: 'PLA Matte',
weight: 1,
count: 1,
link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742684891'
},
{
Hex: '#AE96D5',
Color: 'Lilac Purple',
Material: 'PLA Matte',
Weight: '1kg',
Count: 1,
Link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742914267'
hex: '#AE96D5',
color: 'Lilac Purple',
material: 'PLA Matte',
weight: 1,
count: 1,
link: 'https://eu.store.bambulab.com/en-no/collections/pla/products/pla-matte?variant=42996742914267'
}
];

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,22 @@
export function hexToRgba(hex: string, alpha = 1) {
// Remove leading # if present
hex = hex.replace(/^#/, '');
// Handle shorthand (#fff → #ffffff)
if (hex.length === 3) {
hex = hex
.split('')
.map((c) => c + c)
.join('');
}
if (hex.length !== 6) {
throw new Error('Invalid HEX color.');
}
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View File

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

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -30,8 +30,17 @@
main {
/* mobile: 1rem */
margin: 2rem;
width: 100%;
--margin: 2rem;
margin: var(--margin);
width: calc(100% - var(--margin) * 2);
}
@media screen and (max-width: 750px) {
padding-top: 0;
main {
--margin: 1rem;
}
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
import { addFilament, updateFilament } from '$lib/server/database';
import { json } from '@sveltejs/kit';
export const PUT: RequestHandler = async ({ params, request }) => {
try {
const id = Number(params.id);
if (isNaN(id)) {
return new Response(JSON.stringify({ error: 'Invalid id' }), { status: 400 });
}
const body = await request.json();
await updateFilament({ id, ...body });
return json({ success: true });
} catch (err: any) {
console.log(err);
console.error('Failed to update filament:', err.message);
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
status: 500
});
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData();
// Extract values by input `name` attributes
const hex = formData.get('Hex')?.toString().trim();
const color = formData.get('Color name')?.toString().trim();
const material = formData.get('Material')?.toString().trim();
const weightStr = formData.get('Weight')?.toString().trim();
const link = formData.get('Link')?.toString().trim();
if (!hex || !color || !material || !weightStr || !link) {
return new Response(JSON.stringify({ error: 'All fields are required' }), { status: 400 });
}
// convert "0.5 kg" → 0.5, "1 kg" → 1, etc
const weight = parseFloat(weightStr);
if (!hex || !color) {
return new Response(JSON.stringify({ error: 'hex and color are required' }), {
status: 400
});
}
await addFilament(hex, color, material, weight, link);
return Response.redirect(`${request.url}/${color}`, 303);
} catch (err: any) {
console.log(err);
console.error('Failed to add filament:', err.message);
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
status: 500
});
}
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte';
import ServerComp from '$lib/components/Server.svelte';
import ServerSummary from '$lib/components/ServerSummary.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -10,6 +11,8 @@
<PageHeader>Servers</PageHeader>
<ServerSummary {nodes} />
<div class="server-list">
{#each nodes as node (node.name)}
<div>

View File

@@ -1,11 +1,112 @@
<script>
<script lang="ts">
import PageHeader from '$lib/components/PageHeader.svelte';
import Section from '$lib/components/Section.svelte';
import ThumbnailButton from '$lib/components/ThumbnailButton.svelte';
interface Site {
title: string;
image: string;
link: string;
background?: string;
color?: string;
}
const sites: Array<Site> = [
{
title: 'Grafana',
image: '/images/grafana.png',
link: 'https://grafana.schleppe.cloud',
background: '#F5E3DC',
color: '#F05A24'
},
{
title: 'Prometheus',
image: '/images/prometheus.svg',
link: 'http://prome.schleppe:9090',
background: '#262221',
color: '#F3BFA2'
},
{
title: 'Traefik',
image: '/images/traefik.png',
link: 'https://grafana.schleppe.cloud',
background: '#30A4C2',
color: 'white'
},
{
title: 'Kibana',
image: '/images/kibana.svg',
link: 'https://kibana.schleppe.cloud',
background: '#f6cfdd',
color: '#401C26'
},
{
title: 'HASS',
image: '/images/hass.png',
link: 'http://homeassistant.schleppe:8123',
background: '#1ABCF2',
color: 'white'
},
{
title: 'Vault',
image: '/images/vault.svg',
link: 'http://vault.schleppe:8200',
background: 'white',
color: 'black'
},
{
title: 'Drone',
image: '/images/drone.png',
link: 'https://drone.schleppe.cloud',
background: '#D8E2F0',
color: '#1E375A'
},
{
title: 'Immich',
image: '/images/immich.png',
link: 'http://immich.schleppe:2283',
background: 'white',
color: 'black'
},
{
title: 'Wiki',
image: '/images/xwiki.png',
link: 'https://wiki.schleppe.cloud',
background: 'white',
color: 'black'
},
{
title: 'Gitea',
image: '/images/gitea.png',
link: 'https://git.schleppe.cloud',
background: '#E6E7D7',
color: '#609925'
},
{
title: 'PBS',
image: '/images/proxmox.png',
link: 'https://clio.schleppe:8007',
background: '#EDE1D2',
color: '#E66B00'
}
];
</script>
<PageHeader>Sites</PageHeader>
<div class="section-wrapper">
{#each sites as site}
<ThumbnailButton
title={site.title}
image={site.image}
background={site.background}
color={site.color}
link={site.link}
/>
{/each}
</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."
@@ -26,3 +127,26 @@
description="Connected services can communicate with your application over the private network."
/>
</div>
<style lang="scss">
.section-wrapper {
display: grid;
grid-template-columns: repeat(4, 1fr);
@media screen and (max-width: 1000px) {
grid-template-columns: repeat(3, 1fr);
}
@media screen and (max-width: 750px) {
grid-template-columns: repeat(2, 1fr);
&.full-width {
grid-template-columns: repeat(1, 1fr);
}
}
&:not(:first-of-type) {
margin-top: 4rem;
}
}
</style>

View File

@@ -0,0 +1,221 @@
<script lang="ts">
import { onMount } from 'svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { hexToRgba } from '$lib/utils/color.ts';
import { data } from '$lib/utils/zigbee_devices';
let width = 650;
let originalData = { ...data };
let element;
let hovering;
let Graph;
let state = 0;
export const ssr = false;
function nodeCanvasObject(node, ctx, globalScale) {
const label = node.name;
let baseFontSize = 7;
let exponent = -0.6; // Adjust this value based on testing to find the right feel.
let fontSize = baseFontSize * Math.pow(globalScale, exponent);
ctx.font = `${fontSize}px sans-serif`;
const textWidth = ctx.measureText(label).width;
const bckgDimensions = [textWidth, fontSize].map((n) => n + fontSize * 0.2); // some padding
ctx.beginPath();
let color = groupToColor(node.group);
if (hovering) {
const hoveredNeighbor = hovering?.neighbors?.findIndex((n) => n.ieee === node.ieee);
color = hoveredNeighbor === -1 && hovering.ieee !== node.ieee ? hexToRgba(color, 0.2) : color;
}
ctx.fillStyle = color || 'blue';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, node.x + 5 + 5 * fontSize, node.y); // Adjust position as needed
ctx.arc(node.x, node.y, node.radius || 5, 0, 2 * Math.PI, false);
ctx.fillStyle = color || 'blue';
ctx.fill();
ctx.fill();
}
function groupToColor(group) {
switch (group) {
case 0:
return '#01A9F4';
case 1:
return '#00BCD4';
case 2:
return '#009688';
case 3:
return '#DB4537';
default:
return 'blue';
}
}
function createNodeLabel(node) {
const el = document.createElement('div');
el.style.display = 'flex';
el.style.flexDirection = 'column';
const ob = {
Name: node.name,
IEEE: node.ieee,
'Device type': node.type,
NWK: '0x3cf1',
Device: node.device,
Area: node.area
};
el.innerHTML = Object.entries(ob)
.map(([title, text]) => {
return `<span><b>${title}:</b> ${text}</span>`;
})
.toString()
.replaceAll(',', '');
return el;
}
async function draw() {
const ForceGraph = (await import('force-graph')).default;
Graph = new ForceGraph(element)
.width(800)
.height(550)
.graphData(data)
.linkDirectionalParticles(2)
.linkDirectionalParticleWidth(1.4)
.linkCurvature(0)
.nodeCanvasObject(nodeCanvasObject)
.nodeLabel(createNodeLabel)
.onNodeClick((node) => {
// Center/zoom on node
Graph.centerAt(node.x, node.y, 1000);
Graph.zoom(8, 2000);
})
.onNodeHover((node) => {
hovering = node || null;
console.log(node);
});
setTimeout(() => Graph?.zoomToFit(0, 140), 20);
}
onMount(() => {
draw();
// window.addEventListener('mousemove', trackMouse, false);
console.log(data);
});
function toggleView() {
if (state === 0) {
data.links = originalData.links.filter(
(l) => l.source.ieee === '00:21:2e:ff:ff:09:44:73'
);
} else if (state === 1) {
data.links = originalData.links;
} else if (state === 2) {
data.links = originalData.links;
data.links.shift();
}
state += 1;
if (state === 3) state = 0;
console.log(data);
Graph.graphData(data);
draw();
}
</script>
<PageHeader>Zigbee</PageHeader>
<div>
<button class="affirmative" on:click={toggleView}><span>Toggle topology</span></button>
</div>
<div class="container">
<div class="header">
<span>Coordinator</span>
<span>Router</span>
<span>End device</span>
<span>Offline</span>
</div>
<div id="graph" bind:this={element}></div>
</div>
<style lang="scss">
button span {
padding: 0.25rem 0.5rem;
margin-bottom: 1.5rem;
font-size: 1rem;
font-weight: 400;
letter-spacing: 1px;
font-style: italic;
}
.container {
display: inline-block;
border: 3px solid black;
border-radius: var(--border-radius);
position: relative;
.header {
position: absolute;
height: 2rem;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
span {
margin: 0 1rem;
&:nth-of-type(1)::before {
content: '';
position: absolute;
margin-top: 1px;
margin-left: -1.6rem;
height: 1rem;
width: 1.4rem;
border-radius: 3px;
background-color: #01a9f4;
}
&:nth-of-type(2)::before {
content: '';
position: absolute;
margin-top: 1px;
margin-left: -1.2rem;
height: 1rem;
width: 1rem;
border-radius: 50%;
background-color: #00bcd4;
}
&:nth-of-type(3)::before {
content: '';
position: absolute;
margin-top: 1px;
margin-left: -1.2rem;
height: 1rem;
width: 1rem;
border-radius: 50%;
background-color: #009688;
}
&:nth-of-type(4)::before {
content: '';
position: absolute;
margin-top: 1px;
margin-left: -1.2rem;
height: 1rem;
width: 1rem;
border-radius: 50%;
background-color: #db4537;
}
}
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
static/favicon/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "infra",
"short_name": "infra",
"icons": [
{
"src": "/favicon/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/favicon/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#33292B",
"background_color": "#33292B",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Binary file not shown.

BIN
static/images/drone.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
static/images/gitea.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

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