working nice. docker uses bun

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,165 +0,0 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { V1Pod } from '@kubernetes/client-node';
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 { source } from 'sveltekit-sse';
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;
function setupWS() {
const url = new URL(`${window.location.origin}/cluster/pod/${pod?.metadata?.uid}/logs`);
if (pod?.metadata) {
console.error('missing pod info. not enough metadata to setup WS connection.');
return;
}
url.searchParams.append('pod', pod?.metadata?.name || '');
url.searchParams.append('namespace', pod?.metadata?.namespace || '');
url.searchParams.append('container', pod?.spec?.containers[0].name || '');
eventSource = new EventSource(url);
eventSource.onmessage = function (event) {
logs.update((arr) => arr.concat(event.data));
};
return (eventSource.onerror = (err) => {
console.error('EventSource failed:', err);
});
}
onMount(() => {
setInterval(() => uptime.update((n) => n + 1000), 1000);
return setupWS();
});
onDestroy(() => {
if (eventSource) {
eventSource.close();
console.log('EventSource closed');
}
});
</script>
<PageHeader>Pod: {pod?.metadata?.name}</PageHeader>
<Tabs>
<TabList>
<Tab>Details</Tab>
<Tab>Logs</Tab>
<Tab>Metadata</Tab>
<Tab>Deployment logs</Tab>
</TabList>
<TabView>
<div class="section-wrapper">
<Section title="Status" description="">
<div class="section-row">
<div class="section-element">
<label>Phase</label>
<span>{status?.phase}</span>
</div>
<div class="section-element">
<label>Pod IP</label>
<span>{status?.podIP}</span>
</div>
<div class="section-element">
<label>QoS Class</label>
<span>{status?.qosClass}</span>
</div>
<div class="section-element">
<label>Running for</label>
<span>{formatDuration($uptime / 1000)}</span>
</div>
</div>
</Section>
<Section title="Metadata" description="">
<div class="section-row">
<div class="section-element">
<label>Namespace</label>
<span>{metadata?.namespace}</span>
</div>
<div class="section-element">
<label>Parent resource</label>
<span>{metadata?.ownerReferences?.[0].kind}</span>
</div>
</div>
</Section>
<Section title="Spec" description="">
<div class="section-row">
<div class="section-element">
<label>Node name</label>
<span>{spec?.nodeName}</span>
</div>
<div class="section-element">
<label>Restart policy</label>
<span>{spec?.restartPolicy}</span>
</div>
<div class="section-element">
<label>Service account</label>
<span>{spec?.serviceAccount}</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>
<Logs logs={$logs} stream={true} />
</TabView>
<TabView>
<Logs logs={JSON.stringify(metadata, null, 2).split('\n')} lineNumbers={false} />
</TabView>
<TabView>
<Logs lineNumbers={false} />
</TabView>
</Tabs>

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: #1c1b1b;
}
.meta {
display: flex;
gap: 0.3rem;
flex-direction: column;
color: #6a6a6a;
word-break: break-all;
}
}
}
.color {
--size: 4rem;
display: block;
width: var(--size);
height: var(--size);
border-radius: var(--border-radius, 1rem);
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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