diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..e7e736f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+PROXMOX_URL=
+PROXMOX_TOKEN=
+HOMEASSISTANT_URL=
+HOMEASSISTANT_TOKEN=
+TRAEFIK_URL=
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..e112690
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,23 @@
+FROM node:22-alpine3.20 AS builder
+
+WORKDIR /app
+
+COPY src/ src
+COPY static/ static
+COPY package.json yarn.lock svelte.config.js tsconfig.json vite.config.ts .
+
+RUN yarn
+RUN yarn build
+
+FROM node:22-alpine3.20
+
+WORKDIR /opt/infra-map
+COPY --from=builder /app/build build
+
+COPY package.json .
+RUN yarn
+
+EXPOSE 3000
+ENV NODE_ENV=production
+
+CMD [ "node", "build" ]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b5b2950
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# sv
+
+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
+
+## Creating a project
+
+If you're seeing this, you've probably already done this step. Congrats!
+
+```bash
+# create a new project in the current directory
+npx sv create
+
+# create a new project in my-app
+npx sv create my-app
+```
+
+## Developing
+
+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+
+```bash
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+To create a production version of your app:
+
+```bash
+npm run build
+```
+
+You can preview the production build with `npm run preview`.
+
+> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
diff --git a/src/app.d.ts b/src/app.d.ts
new file mode 100644
index 0000000..da08e6d
--- /dev/null
+++ b/src/app.d.ts
@@ -0,0 +1,13 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/src/app.html b/src/app.html
new file mode 100644
index 0000000..2612ba0
--- /dev/null
+++ b/src/app.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+ {#if displayColumns.length > 0}
+ {#each displayColumns as column (column)}
+ {column}
+ {/each}
+ {:else}
+ {#each columns as column (column)}
+ {column}
+ {/each}
+ {/if}
+
+
+
+ {#each data as row, i (row)}
+ hasLinks && goto(links[i])} class={hasLinks ? 'link' : ''}>
+ {#each columns as column (column)}
+ {#if column === 'Link'}
+ Link
+ {:else if column === 'Hex'}
+
+ {:else if Array.isArray(row[column])}
+ {row[column].join(', ')}
+ {:else}
+ {row[column]}
+ {/if}
+ {/each}
+
+ {/each}
+
+
+
+ {#if footer?.length}
+
+ {/if}
+
+
+
diff --git a/src/lib/components/navigation/Tab.svelte b/src/lib/components/navigation/Tab.svelte
new file mode 100644
index 0000000..b159760
--- /dev/null
+++ b/src/lib/components/navigation/Tab.svelte
@@ -0,0 +1,49 @@
+
+
+ {
+ try {
+ let hassStates = await fetchHassStates();
+
+ hassStates = hassStates.filter(
+ (el: Entity) => el.attributes.friendly_name?.includes('P1P') === true
+ );
+ return hassStates;
+ } catch (error) {
+ console.log('ERROR! from fetchP1P:', error);
+ return Promise.reject(null);
+ }
+}
diff --git a/src/lib/server/kubernetes.ts b/src/lib/server/kubernetes.ts
new file mode 100644
index 0000000..6087847
--- /dev/null
+++ b/src/lib/server/kubernetes.ts
@@ -0,0 +1,116 @@
+import * as k8s from '@kubernetes/client-node';
+import stream from 'stream';
+import { writable } from 'svelte/store';
+
+const context = {
+ name: 'kazan-insecure',
+ user: 'admin',
+ cluster: 'kazan-insecure'
+};
+
+const kc = new k8s.KubeConfig();
+kc.loadFromDefault({ contexts: [context] });
+
+const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
+const appsV1Api = kc.makeApiClient(k8s.AppsV1Api);
+
+const k8sLog = new k8s.Log(kc);
+
+export async function getReplicas() {
+ try {
+ const allReplicas = await appsV1Api.listReplicaSetForAllNamespaces();
+ return allReplicas.items;
+ } catch (error) {
+ console.log('error when getting replicas:', error);
+ return [];
+ }
+}
+
+export async function getPods() {
+ try {
+ const allPods = await k8sApi.listPodForAllNamespaces();
+ return allPods.items;
+ } catch (error) {
+ console.log('error when getting k8s resources:', error);
+ return [];
+ }
+}
+
+export async function getPod(name: string, namespace: string) {
+ try {
+ return await k8sApi.readNamespacedPodTemplate({ name, namespace });
+ } catch (error) {
+ console.log(`error when getting pod:`, error);
+ return undefined;
+ }
+}
+
+export async function getDeployments() {
+ try {
+ const allDeploys = await appsV1Api.listDeploymentForAllNamespaces();
+ return allDeploys.items;
+ } catch (error) {
+ console.log('error when getting deployments:', error);
+ return [];
+ }
+}
+
+export async function getDaemons() {
+ try {
+ const allDaemons = await appsV1Api.listDaemonSetForAllNamespaces();
+ return allDaemons.items;
+ } catch (error) {
+ console.log('error when getting daemons:', error);
+ return [];
+ }
+}
+
+export async function getNodes() {
+ try {
+ const nodes = await k8sApi.listNode();
+ return nodes.items;
+ } catch (error) {
+ console.log('error when getting k8s nodes:', error);
+ return [];
+ }
+}
+
+export function createLogStream(podName: string, namespace: string, containerName: string) {
+ // const logEmitter = new EventTarget(); // use EventTarget or EventEmitter
+ const logEmitter = writable();
+
+ const maxLines = 400;
+ let liveStream: stream.PassThrough | null = null;
+ let logAbortController;
+
+ async function start() {
+ // Live logs
+ liveStream = new stream.PassThrough();
+ liveStream.on('data', (chunk) => {
+ let chunks = chunk.toString().split('\n').filter(Boolean);
+
+ console.log('chynjks length:', chunks?.length);
+ if (chunks?.length > maxLines) {
+ chunks = chunks.slice(maxLines);
+ }
+
+ chunks.forEach((line) => logEmitter.set(line));
+ });
+
+ console.log('setting logAbortController, prev:', logAbortController);
+ logAbortController = await k8sLog.log(namespace, podName, containerName, liveStream, {
+ follow: true,
+ timestamps: false,
+ pretty: false,
+ tailLines: maxLines
+ });
+ }
+
+ function stop() {
+ console.log('ending livestream!!');
+ logAbortController?.abort();
+ liveStream?.end();
+ }
+
+ return { start, stop, logEmitter };
+}
diff --git a/src/lib/server/proxmox.ts b/src/lib/server/proxmox.ts
new file mode 100644
index 0000000..4d9b258
--- /dev/null
+++ b/src/lib/server/proxmox.ts
@@ -0,0 +1,86 @@
+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 options = {
+ method: 'GET',
+ headers: {
+ Authorization: token,
+ 'Content-Type': 'application/json'
+ }
+ };
+
+ return { url, options };
+}
+
+async function fetchNodeVMs(node: Node) {
+ const r = buildProxmoxRequest();
+ r.url += 'nodes/' + node?.name + '/qemu';
+
+ return fetch(r.url, r?.options)
+ .then((resp) => resp.json())
+ .then((response) => response.data);
+}
+
+async function fetchNodeLXCs(node: Node) {
+ const r = buildProxmoxRequest();
+ r.url += 'nodes/' + node?.name + '/lxc';
+
+ return fetch(r.url, r?.options)
+ .then((resp) => resp.json())
+ .then((response) => response.data);
+}
+
+async function fetchNodeInfo(node: Node) {
+ const r = buildProxmoxRequest();
+ r.url += 'nodes/' + node?.name + '/status';
+
+ return fetch(r.url, r?.options)
+ .then((resp) => resp.json())
+ .then((response) => response.data);
+}
+
+async function getClusterInfo() {
+ const r = buildProxmoxRequest();
+ r.url += 'cluster/status';
+
+ return fetch(r.url, r?.options)
+ .then((resp) => resp.json())
+ .then((response) => {
+ const { data } = response;
+ const cluster = data.filter((d: Node | Cluster) => d?.type === 'cluster')[0];
+ const nodes = data.filter((d: Node | Cluster) => d?.type === 'node');
+
+ return { cluster, nodes };
+ });
+}
+
+export async function fetchNodes(): Promise<{ nodes: Node[]; cluster: Cluster | null }> {
+ try {
+ const { nodes, cluster } = await getClusterInfo();
+
+ const infoP = Promise.all(nodes.map((node: Node) => fetchNodeInfo(node)));
+ const vmsP = Promise.all(nodes.map((node: Node) => fetchNodeVMs(node)));
+ const lxcsP = Promise.all(nodes.map((node: Node) => fetchNodeLXCs(node)));
+
+ const [info, vms, lxcs] = await Promise.all([infoP, vmsP, lxcsP]);
+
+ return {
+ cluster,
+ nodes: nodes.map((node: Node, i: number) => {
+ return {
+ ...node,
+ info: info[i],
+ vms: vms[i],
+ lxcs: lxcs[i]
+ };
+ })
+ };
+ } catch (error) {
+ console.log('ERROR from fetchnodes:', error);
+
+ return { nodes: [], cluster: null };
+ }
+}
diff --git a/src/lib/server/traefik.ts b/src/lib/server/traefik.ts
new file mode 100644
index 0000000..c355fe6
--- /dev/null
+++ b/src/lib/server/traefik.ts
@@ -0,0 +1,22 @@
+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 url = `${baseURL}${TRAEFIK_HTTP_URL}/${path}`;
+ const options = {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ };
+
+ return { url, options };
+}
+
+export async function getRouters() {
+ const { url, options } = buildTraefikRequest('routers');
+
+ return fetch(url, options).then((resp) => resp.json());
+}
diff --git a/src/lib/utils/conversion.ts b/src/lib/utils/conversion.ts
new file mode 100644
index 0000000..e2b90f1
--- /dev/null
+++ b/src/lib/utils/conversion.ts
@@ -0,0 +1,46 @@
+export function formatBytes(bytes: number) {
+ if (bytes < 1024) return '0 KB'; // Ensure we don't show bytes, only KB and above
+
+ const units = ['KB', 'MB', 'GB', 'TB'];
+ let unitIndex = -1;
+ let formattedSize = bytes;
+
+ do {
+ formattedSize /= 1024;
+ unitIndex++;
+ } while (formattedSize >= 1024 && unitIndex < units.length - 1);
+
+ return `${formattedSize.toFixed(2)} ${units[unitIndex]}`;
+}
+
+export function formatDuration(seconds: number) {
+ if (seconds === 0) return 'Uptime: 0 days 00:00:00';
+
+ const days = Math.floor(seconds / 86400);
+ seconds %= 86400;
+ const hours = Math.floor(seconds / 3600);
+ seconds %= 3600;
+ const minutes = Math.floor(seconds / 60);
+ seconds %= 60;
+
+ return `${days} days ${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(Math.floor(seconds)).padStart(2, '0')}`;
+}
+
+export function convertKiToHumanReadable(input: string) {
+ const match = input.match(/^(\d+)(Ki)$/);
+ if (!match) return 'Invalid input';
+
+ const kibibytes = parseInt(match[1], 10);
+ const bytes = kibibytes * 1024;
+
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ let i = 0;
+ let humanReadable = bytes;
+
+ while (humanReadable >= 1024 && i < sizes.length - 1) {
+ humanReadable /= 1024;
+ i++;
+ }
+
+ return `${humanReadable.toFixed(2)} ${sizes[i]}`;
+}
diff --git a/src/lib/utils/string.ts b/src/lib/utils/string.ts
new file mode 100644
index 0000000..36294ae
--- /dev/null
+++ b/src/lib/utils/string.ts
@@ -0,0 +1,3 @@
+export function capitalizeFirstLetter(text: string) {
+ return text.replace(/\b\w/g, (char) => char.toUpperCase());
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
new file mode 100644
index 0000000..50eaa51
--- /dev/null
+++ b/src/routes/+layout.svelte
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
new file mode 100644
index 0000000..7710d9c
--- /dev/null
+++ b/src/routes/+page.svelte
@@ -0,0 +1,82 @@
+
+
+Welcome to SvelteKit
+Visit svelte.dev/docs/kit to read the documentation
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
+
+ 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.
+
diff --git a/src/routes/cluster/+page.server.ts b/src/routes/cluster/+page.server.ts
new file mode 100644
index 0000000..ff2f66d
--- /dev/null
+++ b/src/routes/cluster/+page.server.ts
@@ -0,0 +1,90 @@
+import type { PageServerLoad } from './$types';
+import { getPods, getNodes, getDeployments, getDaemons } from '$lib/server/kubernetes';
+import type { V1DaemonSet, V1Deployment, V1Node, V1Pod } from '@kubernetes/client-node';
+
+function filterAndStackNodes(nodes: V1Node[], pods: V1Pod[]) {
+ const getNode = (name: string) => nodes.find((node) => node.metadata?.name === name);
+
+ pods.forEach((pod) => {
+ if (!pod.spec?.nodeName) return;
+ const node = getNode(pod.spec.nodeName);
+ node.pods.push(pod);
+ });
+
+ return nodes;
+}
+
+function filterAndStackDaemons(daemons: V1DaemonSet[], pods: V1Pod[]) {
+ const getDaemon = (name: string) =>
+ daemons.find((daemon: V1DaemonSet) => daemon.metadata?.name === name);
+
+ pods.forEach((pod: V1Pod) => {
+ if (!pod.metadata?.ownerReferences?.[0].name) return;
+
+ const daemon = getDaemon(pod.metadata.ownerReferences[0].name);
+ if (!daemon) return;
+ daemon?.pods.push(pod);
+ });
+
+ return daemons;
+}
+
+function filterAndStackDeploys(deploys: V1Deployment[], pods: V1Pod[]) {
+ const getDeploy = (name: string) =>
+ deploys.find((deploy) => {
+ return (
+ (deploy.spec?.selector.matchLabels?.app &&
+ deploy.spec.selector.matchLabels?.app === name) ||
+ (deploy.metadata?.labels?.['app.kubernetes.io/name'] &&
+ deploy.metadata.labels['app.kubernetes.io/name'] === name) ||
+ (deploy.metadata?.labels?.['k8s-app'] && deploy.metadata.labels['k8s-app'] === name)
+ );
+ });
+
+ pods.forEach((pod) => {
+ const name =
+ pod.metadata?.labels?.['k8s-app'] ||
+ pod.metadata?.labels?.['app.kubernetes.io/name'] ||
+ pod.metadata?.labels?.app ||
+ 'not found';
+
+ const deploy = getDeploy(name);
+ if (!deploy) return;
+ deploy.pods.push(pod);
+ });
+
+ return deploys;
+}
+
+export const load: PageServerLoad = async () => {
+ const [podsResp, nodesResp, deployResp, daemonsResp] = await Promise.all([
+ getPods(),
+ getNodes(),
+ getDeployments(),
+ getDaemons()
+ ]);
+
+ const pods: V1Pod[] = JSON.parse(JSON.stringify(podsResp));
+ let nodes: V1Node[] = JSON.parse(JSON.stringify(nodesResp));
+ let deployments: V1Deployment[] = JSON.parse(JSON.stringify(deployResp));
+ let daemons: V1DaemonSet[] = JSON.parse(JSON.stringify(daemonsResp));
+
+ nodes.forEach((node) => (node['pods'] = []));
+ deployments.forEach((deploy) => (deploy['pods'] = []));
+ daemons.forEach((daemon) => (daemon['pods'] = []));
+ nodes = filterAndStackNodes(nodes, pods);
+ deployments = filterAndStackDeploys(deployments, pods);
+ daemons = filterAndStackDaemons(daemons, pods);
+
+ // TODO move to frontend
+ deployments = deployments.sort((a, b) =>
+ a.metadata?.name && b.metadata?.name && a.metadata?.name > b.metadata?.name ? 1 : -1
+ );
+
+ return {
+ nodes,
+ deployments,
+ daemons,
+ pods
+ };
+};
diff --git a/src/routes/cluster/+page.svelte b/src/routes/cluster/+page.svelte
new file mode 100644
index 0000000..3b36ee7
--- /dev/null
+++ b/src/routes/cluster/+page.svelte
@@ -0,0 +1,94 @@
+
+
+Cluster overview
+
+
+
+ Cluster {nodes.length} nodes
+
+
+ {#each nodes as node (node)}
+
+ {/each}
+
+
+
+
+
+
+ Daemons {daemons.length} daemons ({daemons.reduce(
+ (total, item) => total + (item.pods ? item.pods.length : 0),
+ 0
+ )} pods)
+
+
+
+
+ {#each daemons as daemon (daemon)}
+
+ {/each}
+
+
+
+
+
+
+ Pods {deployments.length} deployments ({deployments.reduce(
+ (total, item) => total + (item.pods ? item.pods.length : 0),
+ 0
+ )} pods)
+
+
+
+
+ {#each deployments as deploy (deploy)}
+
+ {/each}
+
+
+
+
diff --git a/src/routes/cluster/pod/[uid]/+page.server.ts b/src/routes/cluster/pod/[uid]/+page.server.ts
new file mode 100644
index 0000000..3218f20
--- /dev/null
+++ b/src/routes/cluster/pod/[uid]/+page.server.ts
@@ -0,0 +1,17 @@
+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
+ };
+};
diff --git a/src/routes/cluster/pod/[uid]/+page.svelte b/src/routes/cluster/pod/[uid]/+page.svelte
new file mode 100644
index 0000000..4ef89a7
--- /dev/null
+++ b/src/routes/cluster/pod/[uid]/+page.svelte
@@ -0,0 +1,165 @@
+
+
+Pod: {pod?.metadata?.name}
+
+
+
+ Details
+ Logs
+ Metadata
+ Deployment logs
+
+
+
+
+
+
+
+ Phase
+ {status?.phase}
+
+
+
+ Pod IP
+ {status?.podIP}
+
+
+
+ QoS Class
+ {status?.qosClass}
+
+
+
+ Running for
+ {formatDuration($uptime / 1000)}
+
+
+
+
+
+
+
+ Namespace
+ {metadata?.namespace}
+
+
+
+ Parent resource
+ {metadata?.ownerReferences?.[0].kind}
+
+
+
+
+
+
+
+ Node name
+ {spec?.nodeName}
+
+
+
+ Restart policy
+ {spec?.restartPolicy}
+
+
+
+ Service account
+ {spec?.serviceAccount}
+
+
+
+ Scheduler
+ {spec?.schedulerName}
+
+
+
+ Host network
+ {spec?.hostNetwork ? 'yes' : 'no'}
+
+
+
+ DNS policy
+ {spec?.dnsPolicy}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/routes/cluster/pod/[uid]/logs/+server.ts b/src/routes/cluster/pod/[uid]/logs/+server.ts
new file mode 100644
index 0000000..6090cbc
--- /dev/null
+++ b/src/routes/cluster/pod/[uid]/logs/+server.ts
@@ -0,0 +1,27 @@
+import { createLogStream } from '$lib/server/kubernetes';
+import { produce } from 'sveltekit-sse';
+
+export function GET({ request }) {
+ return produce(async function start({ emit }) {
+ console.log('----- REQUEST -----');
+ const url = new URL(request.url);
+ const pod = url.searchParams.get('pod');
+ const namespace = url.searchParams.get('namespace');
+ const container = url.searchParams.get('container');
+
+ console.log('pod, namespace:', pod, namespace);
+ const k8sLogs = createLogStream(pod, namespace, container);
+ k8sLogs.start();
+ const unsubscribe = k8sLogs.logEmitter.subscribe((msg: string) => {
+ emit('message', msg);
+ });
+
+ const { error } = emit('message', `the time is ${Date.now()}`);
+
+ if (error) {
+ k8sLogs.stop();
+ unsubscribe();
+ return;
+ }
+ });
+}
diff --git a/src/routes/health/+page.svelte b/src/routes/health/+page.svelte
new file mode 100644
index 0000000..d9840fe
--- /dev/null
+++ b/src/routes/health/+page.svelte
@@ -0,0 +1,20 @@
+
+
+Health
+
+
diff --git a/src/routes/network/+page.server.ts b/src/routes/network/+page.server.ts
new file mode 100644
index 0000000..558280f
--- /dev/null
+++ b/src/routes/network/+page.server.ts
@@ -0,0 +1,26 @@
+import type { PageServerLoad } from './$types';
+import { getRouters } from '$lib/server/traefik';
+
+let cache = {
+ timestamp: 0,
+ data: null
+};
+
+export const load: PageServerLoad = async () => {
+ const now = Date.now();
+
+ if (cache.data && now - cache.timestamp < 10000) {
+ console.log('Serving from cache');
+ return {
+ routers: cache.data
+ };
+ }
+
+ const routers = await getRouters();
+
+ cache = { timestamp: now, data: routers };
+
+ return {
+ routers
+ };
+};
diff --git a/src/routes/network/+page.svelte b/src/routes/network/+page.svelte
new file mode 100644
index 0000000..bd74ae1
--- /dev/null
+++ b/src/routes/network/+page.svelte
@@ -0,0 +1,56 @@
+
+
+Network
+
+
+
+
+
+ Number of routers
+ {routers.length}
+
+
+
+ Providers
+ {providers?.join(', ')}
+
+
+
+
+
+
+
+
diff --git a/src/routes/network/[id]/+page.server.ts b/src/routes/network/[id]/+page.server.ts
new file mode 100644
index 0000000..dfc9b98
--- /dev/null
+++ b/src/routes/network/[id]/+page.server.ts
@@ -0,0 +1,29 @@
+import type { PageServerLoad } from './$types';
+import { getRouters } from '$lib/server/traefik';
+
+const cache = {
+ timestamp: 0,
+ data: {}
+};
+
+export const load: PageServerLoad = async ({ params }) => {
+ const now = Date.now();
+ const { id } = params;
+
+ if (cache.data[id] && now - cache.timestamp < 10000) {
+ console.log('Serving from cache');
+ return {
+ router: cache.data[id]
+ };
+ }
+
+ const routers = await getRouters();
+ const router = routers.find((router) => router.service === id);
+
+ cache.time = now;
+ cache.data[id] = router;
+
+ return {
+ router
+ };
+};
diff --git a/src/routes/network/[id]/+page.svelte b/src/routes/network/[id]/+page.svelte
new file mode 100644
index 0000000..2ed3c47
--- /dev/null
+++ b/src/routes/network/[id]/+page.svelte
@@ -0,0 +1,15 @@
+
+
+Network: {router.service}
+
+
+
router:
+
{JSON.stringify(router, null, 2)}
+
diff --git a/src/routes/printer/+page.server.ts b/src/routes/printer/+page.server.ts
new file mode 100644
index 0000000..32c9ae2
--- /dev/null
+++ b/src/routes/printer/+page.server.ts
@@ -0,0 +1,11 @@
+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 type { Filament } from '$lib/interfaces/printer';
+
+export const load: PageServerLoad = async (): Promise<{ p1p: Entity[]; filament: Filament[] }> => {
+ const p1p = await fetchP1P();
+ const filament = currentFilament();
+ return { p1p, filament };
+};
diff --git a/src/routes/printer/+page.svelte b/src/routes/printer/+page.svelte
new file mode 100644
index 0000000..99c06c8
--- /dev/null
+++ b/src/routes/printer/+page.svelte
@@ -0,0 +1,237 @@
+
+
+Printer
+
+
+
+
+
+ Current stage
+
+
+ {currentStage.state}
+
+
+
+ Bed temperature
+ {bedTemp.state}
+ {bedTemp.attributes.unit_of_measurement}
+
+
+
+ Nozzle temperature
+ {nozzleTemp.state}
+ {nozzleTemp.attributes.unit_of_measurement}
+
+
+
+ Print status
+
+
+
+ {printStatus.state}
+
+
+
+
+
+
+ {#if currentLayer.state !== totalLayer.state}
+
Currently printing layer line {currentLayer.state} of {totalLayer.state}
+ {:else}
+
Finished printing {currentLayer.state} of {totalLayer.state} layers!
+ {/if}
+
+
+
+
+
+
+ Total print time
+
+ {Math.floor(Number(totalUsage.state) * 10) / 10}
+
+ {totalUsage.attributes.unit_of_measurement}
+
+
+
+ Nozzle Type
+ {capitalizeFirstLetter(nozzleType.state.replaceAll('_', ' '))}
+ {nozzleType.attributes.unit_of_measurement}
+
+
+
+ Nozzle Size
+ {nozzleSize?.state} {nozzleSize.attributes.unit_of_measurement}
+
+
+
+ Bed type
+ {capitalizeFirstLetter(bedType?.state?.replaceAll('_', ' ') || 'not found')}
+ {bedType?.attributes.unit_of_measurement}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/routes/printer/[id]/+page.server.ts b/src/routes/printer/[id]/+page.server.ts
new file mode 100644
index 0000000..a1051b1
--- /dev/null
+++ b/src/routes/printer/[id]/+page.server.ts
@@ -0,0 +1,12 @@
+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 };
+};
diff --git a/src/routes/printer/[id]/+page.svelte b/src/routes/printer/[id]/+page.svelte
new file mode 100644
index 0000000..dc77ee6
--- /dev/null
+++ b/src/routes/printer/[id]/+page.svelte
@@ -0,0 +1,30 @@
+
+
+{#if filament !== null}
+ Filament: {filament?.Color}
+
+
+{:else}
+ Filament not found!
+
+ Unable to find filament {data.id}, no swatch to display.
+{/if}
+
+
diff --git a/src/routes/servers/+page.server.ts b/src/routes/servers/+page.server.ts
new file mode 100644
index 0000000..9aeadc7
--- /dev/null
+++ b/src/routes/servers/+page.server.ts
@@ -0,0 +1,43 @@
+import type { PageServerLoad } from './$types';
+import type { Node, Cluster } from '$lib/interfaces/proxmox';
+import { fetchNodes } from '$lib/server/proxmox';
+
+const TTL = 10000; // 10 seconds
+
+interface ClusterCache {
+ timestamp: number;
+ data: {
+ nodes: Node[];
+ cluster: Cluster | null;
+ };
+}
+
+let cache: ClusterCache = {
+ timestamp: 0,
+ data: {
+ nodes: [],
+ cluster: null
+ }
+};
+
+export const load: PageServerLoad = async () => {
+ const now = Date.now();
+ const hit = cache.data.cluster && cache.data.nodes?.length && now - cache.timestamp < TTL;
+
+ if (hit) {
+ const { nodes, cluster } = cache.data;
+ return { nodes, cluster };
+ }
+
+ const { nodes, cluster } = await fetchNodes();
+ nodes.sort((a: Node, b: Node) => {
+ return a.name.toUpperCase() < b.name.toUpperCase() ? -1 : 1;
+ });
+
+ cache = { timestamp: now, data: { nodes, cluster } };
+
+ return {
+ nodes,
+ cluster
+ };
+};
diff --git a/src/routes/servers/+page.svelte b/src/routes/servers/+page.svelte
new file mode 100644
index 0000000..5736377
--- /dev/null
+++ b/src/routes/servers/+page.svelte
@@ -0,0 +1,29 @@
+
+
+Servers
+
+
+ {#each nodes as node (node.name)}
+
+
+
+ {/each}
+
+
+
diff --git a/src/routes/sites/+page.svelte b/src/routes/sites/+page.svelte
new file mode 100644
index 0000000..620a8a9
--- /dev/null
+++ b/src/routes/sites/+page.svelte
@@ -0,0 +1,28 @@
+
+
+Sites
+
+
+
+
+
+
+
+
+
+
diff --git a/static/favicon.png b/static/favicon.png
new file mode 100644
index 0000000..825b9e6
Binary files /dev/null and b/static/favicon.png differ
diff --git a/static/fonts/Inter.woff2 b/static/fonts/Inter.woff2
new file mode 100644
index 0000000..e0cab47
Binary files /dev/null and b/static/fonts/Inter.woff2 differ
diff --git a/static/fonts/RecklessNeue-Regular.woff2 b/static/fonts/RecklessNeue-Regular.woff2
new file mode 100644
index 0000000..b6a1cd4
Binary files /dev/null and b/static/fonts/RecklessNeue-Regular.woff2 differ
diff --git a/static/logo.png b/static/logo.png
new file mode 100644
index 0000000..f148b73
Binary files /dev/null and b/static/logo.png differ
diff --git a/static/logo_grey.png b/static/logo_grey.png
new file mode 100644
index 0000000..e2d59de
Binary files /dev/null and b/static/logo_grey.png differ
diff --git a/static/logo_light.png b/static/logo_light.png
new file mode 100644
index 0000000..9d6eeb5
Binary files /dev/null and b/static/logo_light.png differ
diff --git a/static/printer.png b/static/printer.png
new file mode 100644
index 0000000..9c0c526
Binary files /dev/null and b/static/printer.png differ
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..fbe37df
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,146 @@
+@font-face {
+ font-family: 'Inter';
+ font-style: normal;
+ src: url('/fonts/Inter.woff2') format('woff2');
+ font-weight: 100 900;
+ font-style: normal;
+}
+
+:root {
+ --bg: #f9f5f3;
+ --color: #1c1819;
+ --highlight: #eaddd5;
+
+ --positive: #00d439;
+ --negative: #ff5449;
+ --warning: #ffa312;
+
+ --border: 1px solid #eaddd5;
+ --border-radius: 0.75rem;
+}
+
+body {
+ font-family: 'Inter', sans-serif;
+ font-optical-sizing: auto;
+ margin: 0;
+ padding: 0;
+
+ background-color: var(--bg);
+ color: var(--color);
+ font-size: 14px;
+}
+
+a,
+a:visited {
+ color: var(--color);
+ text-decoration-line: none;
+}
+
+h1 {
+ font-family: 'Reckless Neue';
+}
+
+h2 {
+ font-size: 20px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--str-space-100, 8px);
+ flex: 1 1 auto;
+ /* align-self: center; */
+ align-self: left;
+ /* text-transform: capitalize; */
+ flex-wrap: wrap;
+ font: var(
+ --str-font-heading-regular-l,
+ 400 1.429rem/1.4 Inter,
+ Arial,
+ -apple-system,
+ BlinkMacSystemFont,
+ sans-serif
+ );
+ margin-top: 0;
+ color: var(--str-color-text-neutral-default);
+ font-weight: 500;
+ line-height: 100%;
+}
+
+button {
+ border: none;
+ position: relative;
+ background: transparent;
+ height: unset;
+ border-radius: 0.5rem;
+ display: inline-block;
+ text-decoration: none;
+ padding: 0 0.5rem;
+ flex: 1;
+}
+button span {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 1.5rem;
+ padding: 0 0.5rem;
+ margin-left: -0.5rem;
+ border: 1px solid #eaddd5;
+ border-radius: inherit;
+ white-space: nowrap;
+ cursor: pointer;
+ font-weight: 700;
+ transition: all ease-in-out 0.2s;
+}
+
+button::after {
+ content: '';
+ position: absolute;
+ right: 0;
+ top: 0;
+ border-radius: 0.5rem;
+ width: 100%;
+ height: 100%;
+ transition: transform 0.1s ease;
+ will-change: box-shadow 0.25s;
+ pointer-events: none;
+}
+
+button:hover span {
+ border-color: #cab2aa;
+ background: #f9f5f3 !important;
+}
+
+.main-container {
+ background: white;
+ padding: 1.5rem;
+ border: var(--border);
+ border-radius: var(--border-radius, 1rem);
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.section-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+.section-row {
+ display: flex;
+ gap: 2rem;
+}
+
+.section-element {
+ display: flex;
+ flex-direction: column;
+}
+
+.section-element label {
+ font-weight: 500;
+ font-size: 15px;
+}
+
+.section-element > span {
+ display: flex;
+ align-items: center;
+ margin-top: 0.5rem;
+}