+
+
+
Docker
+
{containers.length} containers · {containers.filter(c => c.status === "running").length} running
- {/each}
-
-
-{#if logTarget}
-
-
-
Logs: {logTarget}
-
-
-
{logs}
+
-{/if}
+
+ {#if loading}
+
+ {#each Array(5) as _}
+
+ {/each}
+
+ {:else}
+
+ {#each containers as c}
+
+
+
+ {#if c.status === "running"}
+
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {#if c.status === "running"}
+
+
+ {:else}
+
+ {/if}
+
+
+
+ {/each}
+
+ {/if}
+
+
+ {#if logTarget}
+
{ logTarget = ""; logContent = ""; }} onkeydown={(e) => e.key === "Escape" && (logTarget = "", logContent = "")}>
+
e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
+
+
+
Container Logs
+
{logTarget}
+
+
+
+
+ {#if loadingLogs}
+
+ {:else}
+
{logContent}
+ {/if}
+
+
+
+ {/if}
+
diff --git a/dashboard/frontend/src/routes/Files.svelte b/dashboard/frontend/src/routes/Files.svelte
index 8a93f02..f0d9166 100644
--- a/dashboard/frontend/src/routes/Files.svelte
+++ b/dashboard/frontend/src/routes/Files.svelte
@@ -1,63 +1,151 @@
-
-
- {#each currentPath.split("/").filter(Boolean) as part, i}
-
/
-
- {/each}
- {#if currentPath}
-
+
+
+
+
Files
+
/volume1{currentPath ? "/" + currentPath : ""}
+
+
+
+
+
+
+
+
+
+
+ {#each crumbs as part, i}
+
+
+ {/each}
+ {#if currentPath}
+
+ {/if}
+
+
+
+ {#if loading}
+
+ {#each Array(8) as _}
+
+ {/each}
+
+ {:else}
+
+
+
+ Name
+ Size
+ Actions
+
+
+ {#each entries as e}
+
+ {#if e.is_dir}
+
+ {:else}
+
+
+ {e.name}
+
+ {/if}
+
{e.is_dir ? "—" : formatSize(e.size)}
+
+ {#if !e.is_dir}
+
DL
+ {/if}
+
+
+
+ {/each}
+ {#if entries.length === 0}
+
Empty directory
+ {/if}
+
{/if}
-
-
- {#each entries as e}
-
- {#if e.is_dir}
-
- {:else}
-
{e.name}
- {/if}
-
-
{e.is_dir ? "" : formatSize(e.size)}
- {#if !e.is_dir}
-
Download
- {/if}
-
-
-
- {/each}
-
diff --git a/dashboard/frontend/src/routes/Gitea.svelte b/dashboard/frontend/src/routes/Gitea.svelte
index f19aa12..c97a5c5 100644
--- a/dashboard/frontend/src/routes/Gitea.svelte
+++ b/dashboard/frontend/src/routes/Gitea.svelte
@@ -1,38 +1,124 @@
-
Gitea Repos
-
- {#each repos as r}
-
- {/each}
-
+
+
+
Repositories
+
{repos.length} repositories on Gitea
+
-{#if selectedRepo}
-
-
Commits: {selectedRepo}
-
- {#each commits.slice(0, 20) as c}
-
-
{c.commit?.message?.split("\n")[0]}
-
{c.commit?.author?.name} - {new Date(c.commit?.author?.date).toLocaleString()}
-
+ {#if loading}
+
+ {#each Array(4) as _}
+
{/each}
-
-{/if}
+ {:else}
+
+ {#each repos as r}
+
+ {/each}
+
+ {/if}
+
+
+ {#if selectedRepo}
+
+
+
+
Recent Commits
+
{selectedRepo}
+
+
+
+ {#if loadingCommits}
+
+ {#each Array(3) as _}
+
+ {/each}
+
+ {:else}
+
+ {#each commits.slice(0, 15) as c, i}
+
+
+
+ {#if i < commits.length - 1}
+
+ {/if}
+
+
+
{c.commit?.message?.split("\n")[0]}
+
+ {c.commit?.author?.name} · {timeAgo(c.commit?.author?.date)}
+
+
+
{c.sha?.slice(0, 7)}
+
+ {/each}
+
+ {/if}
+
+ {/if}
+
diff --git a/dashboard/frontend/src/routes/Terminal.svelte b/dashboard/frontend/src/routes/Terminal.svelte
index 51e1657..20b11d0 100644
--- a/dashboard/frontend/src/routes/Terminal.svelte
+++ b/dashboard/frontend/src/routes/Terminal.svelte
@@ -2,46 +2,105 @@
import { onMount } from "svelte";
let termEl;
+ let connected = $state(false);
+ let error = $state("");
onMount(async () => {
- const { Terminal } = await import("@xterm/xterm");
- const { FitAddon } = await import("@xterm/addon-fit");
- await import("@xterm/xterm/css/xterm.css");
+ try {
+ const { Terminal } = await import("@xterm/xterm");
+ const { FitAddon } = await import("@xterm/addon-fit");
+ await import("@xterm/xterm/css/xterm.css");
- const term = new Terminal({ cursorBlink: true, fontSize: 14, theme: { background: "#1a1a2e" } });
- const fit = new FitAddon();
- term.loadAddon(fit);
- term.open(termEl);
- fit.fit();
-
- const proto = location.protocol === "https:" ? "wss:" : "ws:";
- const ws = new WebSocket(`${proto}//${location.host}/ws/terminal`);
- ws.binaryType = "arraybuffer";
-
- ws.onopen = () => {
- const buf = new Uint8Array(5);
- buf[0] = 0x01;
- new DataView(buf.buffer).setUint16(1, term.cols);
- new DataView(buf.buffer).setUint16(3, term.rows);
- ws.send(buf);
- };
- ws.onmessage = (e) => term.write(new Uint8Array(e.data));
- ws.onclose = () => term.write("\r\n[Connection closed]");
- term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data)));
-
- const ro = new ResizeObserver(() => {
+ const term = new Terminal({
+ cursorBlink: true,
+ fontSize: 13,
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', monospace",
+ lineHeight: 1.4,
+ theme: {
+ background: "#0f172a",
+ foreground: "#e2e8f0",
+ cursor: "#6366f1",
+ cursorAccent: "#0f172a",
+ selectionBackground: "#334155",
+ black: "#0f172a",
+ red: "#f43f5e",
+ green: "#10b981",
+ yellow: "#f59e0b",
+ blue: "#3b82f6",
+ magenta: "#a855f7",
+ cyan: "#06b6d4",
+ white: "#e2e8f0",
+ },
+ });
+ const fit = new FitAddon();
+ term.loadAddon(fit);
+ term.open(termEl);
fit.fit();
- if (ws.readyState === 1) {
+
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ const ws = new WebSocket(`${proto}//${location.host}/ws/terminal`);
+ ws.binaryType = "arraybuffer";
+
+ ws.onopen = () => {
+ connected = true;
const buf = new Uint8Array(5);
buf[0] = 0x01;
new DataView(buf.buffer).setUint16(1, term.cols);
new DataView(buf.buffer).setUint16(3, term.rows);
ws.send(buf);
- }
- });
- ro.observe(termEl);
+ };
+ ws.onmessage = (e) => term.write(new Uint8Array(e.data));
+ ws.onclose = () => {
+ connected = false;
+ term.write("\r\n\x1b[90m[Connection closed]\x1b[0m");
+ };
+ ws.onerror = () => {
+ error = "WebSocket connection failed. Terminal requires Tailscale access.";
+ };
+ term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data)));
+
+ const ro = new ResizeObserver(() => {
+ fit.fit();
+ if (ws.readyState === 1) {
+ const buf = new Uint8Array(5);
+ buf[0] = 0x01;
+ new DataView(buf.buffer).setUint16(1, term.cols);
+ new DataView(buf.buffer).setUint16(3, term.rows);
+ ws.send(buf);
+ }
+ });
+ ro.observe(termEl);
+ } catch (e) {
+ error = "Failed to initialize terminal: " + e.message;
+ }
});
-
Terminal
-
+
+
+
+
Terminal
+
+ {#if connected}
+
+
+ Connected
+
+ {:else if error}
+ {error}
+ {:else}
+
+
+ Connecting...
+
+ {/if}
+
+
+
+
+
+