Add NAS dashboard MVP: Docker mgmt, Gitea, Files, Terminal
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import Sidebar from "./components/Sidebar.svelte";
|
||||
import Dashboard from "./routes/Dashboard.svelte";
|
||||
import Docker from "./routes/Docker.svelte";
|
||||
import Gitea from "./routes/Gitea.svelte";
|
||||
import Files from "./routes/Files.svelte";
|
||||
import Terminal from "./routes/Terminal.svelte";
|
||||
|
||||
let page = $state("dashboard");
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen bg-white text-gray-900">
|
||||
<Sidebar bind:page />
|
||||
<main class="flex-1 overflow-auto p-6">
|
||||
{#if page === "dashboard"}
|
||||
<Dashboard />
|
||||
{:else if page === "docker"}
|
||||
<Docker />
|
||||
{:else if page === "gitea"}
|
||||
<Gitea />
|
||||
{:else if page === "files"}
|
||||
<Files />
|
||||
{:else if page === "terminal"}
|
||||
<Terminal />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,42 @@
|
||||
<script>
|
||||
let { page = $bindable("dashboard") } = $props();
|
||||
const links = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: "■" },
|
||||
{ id: "docker", label: "Docker", icon: "▶" },
|
||||
{ id: "gitea", label: "Gitea", icon: "📁" },
|
||||
{ id: "files", label: "Files", icon: "📄" },
|
||||
{ id: "terminal", label: "Terminal", icon: ">" },
|
||||
];
|
||||
const external = [
|
||||
{ label: "Navidrome", href: "//:4533", port: 4533 },
|
||||
{ label: "Immich", href: "//:2283", port: 2283 },
|
||||
{ label: "Emby", href: "//:8096", port: 8096 },
|
||||
{ label: "Gitea Web", href: "//:3300", port: 3300 },
|
||||
];
|
||||
function extHref(port) {
|
||||
return `${location.protocol}//${location.hostname}:${port}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="w-56 bg-gray-50 border-r border-gray-200 h-screen flex flex-col p-4 shrink-0">
|
||||
<h1 class="text-lg font-bold mb-6">NAS Dashboard</h1>
|
||||
<nav class="flex flex-col gap-1">
|
||||
{#each links as link}
|
||||
<button
|
||||
class="text-left px-3 py-2 rounded text-sm {page === link.id ? 'bg-blue-100 text-blue-700 font-medium' : 'hover:bg-gray-100 text-gray-700'}"
|
||||
onclick={() => page = link.id}
|
||||
>
|
||||
<span class="mr-2">{@html link.icon}</span>{link.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
<hr class="my-4 border-gray-200" />
|
||||
<p class="text-xs text-gray-400 mb-2 px-3">External Services</p>
|
||||
<nav class="flex flex-col gap-1">
|
||||
{#each external as svc}
|
||||
<a href={extHref(svc.port)} target="_blank" class="text-left px-3 py-2 rounded text-sm hover:bg-gray-100 text-gray-700">
|
||||
{svc.label} ↗
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -0,0 +1,13 @@
|
||||
const BASE = "/api";
|
||||
export async function get(path) {
|
||||
const r = await fetch(BASE + path);
|
||||
return r.json();
|
||||
}
|
||||
export async function post(path) {
|
||||
const r = await fetch(BASE + path, { method: "POST" });
|
||||
return r.json();
|
||||
}
|
||||
export async function del(path) {
|
||||
const r = await fetch(BASE + path, { method: "DELETE" });
|
||||
return r.json();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import "./app.css";
|
||||
import App from "./App.svelte";
|
||||
import { mount } from "svelte";
|
||||
|
||||
const app = mount(App, { target: document.getElementById("app") });
|
||||
export default app;
|
||||
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
import { get } from "../lib/api.js";
|
||||
let containers = $state([]);
|
||||
let repos = $state([]);
|
||||
async function load() {
|
||||
containers = await get("/docker/containers").catch(() => []);
|
||||
const data = await get("/gitea/repos").catch(() => ({ data: [] }));
|
||||
repos = data?.data || [];
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
|
||||
<h2 class="text-2xl font-bold mb-6">Dashboard</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-semibold mb-3">Docker Containers</h3>
|
||||
<p class="text-3xl font-bold">{containers.length}</p>
|
||||
<p class="text-sm text-gray-500">{containers.filter(c => c.status === "running").length} running</p>
|
||||
</div>
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-semibold mb-3">Gitea Repos</h3>
|
||||
<p class="text-3xl font-bold">{repos.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script>
|
||||
import { get, post } from "../lib/api.js";
|
||||
let containers = $state([]);
|
||||
let logs = $state("");
|
||||
let logTarget = $state("");
|
||||
async function load() { containers = await get("/docker/containers"); }
|
||||
async function action(id, act) { await post(`/docker/containers/${id}/${act}`); await load(); }
|
||||
async function showLogs(id, name) { logTarget = name; const r = await get(`/docker/containers/${id}/logs?tail=100`); logs = r.logs; }
|
||||
load();
|
||||
</script>
|
||||
|
||||
<h2 class="text-2xl font-bold mb-6">Docker Containers</h2>
|
||||
<div class="space-y-3">
|
||||
{#each containers as c}
|
||||
<div class="border rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">{c.name}</span>
|
||||
<span class="ml-2 text-xs px-2 py-0.5 rounded {c.status === 'running' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}">{c.status}</span>
|
||||
<p class="text-xs text-gray-400 mt-1">{c.image}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if c.status === "running"}
|
||||
<button class="text-xs px-3 py-1 bg-red-50 text-red-600 rounded hover:bg-red-100" onclick={() => action(c.id, "stop")}>Stop</button>
|
||||
<button class="text-xs px-3 py-1 bg-yellow-50 text-yellow-600 rounded hover:bg-yellow-100" onclick={() => action(c.id, "restart")}>Restart</button>
|
||||
{:else}
|
||||
<button class="text-xs px-3 py-1 bg-green-50 text-green-600 rounded hover:bg-green-100" onclick={() => action(c.id, "start")}>Start</button>
|
||||
{/if}
|
||||
<button class="text-xs px-3 py-1 bg-gray-50 text-gray-600 rounded hover:bg-gray-100" onclick={() => showLogs(c.id, c.name)}>Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if logTarget}
|
||||
<div class="mt-6">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h3 class="font-semibold">Logs: {logTarget}</h3>
|
||||
<button class="text-xs text-gray-400 hover:text-gray-600" onclick={() => { logTarget = ""; logs = ""; }}>Close</button>
|
||||
</div>
|
||||
<pre class="bg-gray-900 text-green-400 text-xs p-4 rounded-lg overflow-auto max-h-96 whitespace-pre-wrap">{logs}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,63 @@
|
||||
<script>
|
||||
import { get, del } from "../lib/api.js";
|
||||
let entries = $state([]);
|
||||
let currentPath = $state("");
|
||||
async function browse(path) {
|
||||
currentPath = path;
|
||||
const data = await get(`/files/browse?path=${encodeURIComponent(path)}`);
|
||||
entries = data.entries || [];
|
||||
}
|
||||
function goUp() {
|
||||
const parts = currentPath.split("/").filter(Boolean);
|
||||
parts.pop();
|
||||
browse(parts.join("/"));
|
||||
}
|
||||
async function remove(name) {
|
||||
if (!confirm(`Delete ${name}?`)) return;
|
||||
const path = currentPath ? `${currentPath}/${name}` : name;
|
||||
await del(`/files/delete?path=${encodeURIComponent(path)}`);
|
||||
browse(currentPath);
|
||||
}
|
||||
function downloadUrl(name) {
|
||||
const path = currentPath ? `${currentPath}/${name}` : name;
|
||||
return `/api/files/download?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
return `${(bytes / 1073741824).toFixed(1)} GB`;
|
||||
}
|
||||
browse("");
|
||||
</script>
|
||||
|
||||
<h2 class="text-2xl font-bold mb-4">Files</h2>
|
||||
<div class="flex items-center gap-2 mb-4 text-sm text-gray-500">
|
||||
<button class="hover:text-blue-600" onclick={() => browse("")}>/volume1</button>
|
||||
{#each currentPath.split("/").filter(Boolean) as part, i}
|
||||
<span>/</span>
|
||||
<button class="hover:text-blue-600" onclick={() => browse(currentPath.split("/").slice(0, i + 1).join("/"))}>{part}</button>
|
||||
{/each}
|
||||
{#if currentPath}
|
||||
<button class="ml-4 text-xs px-2 py-1 bg-gray-100 rounded hover:bg-gray-200" onclick={goUp}>Up</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg divide-y">
|
||||
{#each entries as e}
|
||||
<div class="flex items-center justify-between px-4 py-2 hover:bg-gray-50">
|
||||
{#if e.is_dir}
|
||||
<button class="text-blue-600 hover:underline text-sm" onclick={() => browse(currentPath ? `${currentPath}/${e.name}` : e.name)}>{e.name}/</button>
|
||||
{:else}
|
||||
<span class="text-sm">{e.name}</span>
|
||||
{/if}
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-gray-400">{e.is_dir ? "" : formatSize(e.size)}</span>
|
||||
{#if !e.is_dir}
|
||||
<a href={downloadUrl(e.name)} class="text-xs text-blue-500 hover:underline">Download</a>
|
||||
{/if}
|
||||
<button class="text-xs text-red-400 hover:text-red-600" onclick={() => remove(e.name)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script>
|
||||
import { get } from "../lib/api.js";
|
||||
let repos = $state([]);
|
||||
let commits = $state([]);
|
||||
let selectedRepo = $state("");
|
||||
async function load() { const data = await get("/gitea/repos"); repos = data?.data || []; }
|
||||
async function showCommits(owner, repo) {
|
||||
selectedRepo = `${owner}/${repo}`;
|
||||
const data = await get(`/gitea/repos/${owner}/${repo}/commits`);
|
||||
commits = Array.isArray(data) ? data : [];
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
|
||||
<h2 class="text-2xl font-bold mb-6">Gitea Repos</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{#each repos as r}
|
||||
<button class="border rounded-lg p-4 text-left hover:bg-gray-50" onclick={() => showCommits(r.owner?.login, r.name)}>
|
||||
<span class="font-medium">{r.full_name}</span>
|
||||
<p class="text-xs text-gray-400 mt-1">{r.description || "No description"}</p>
|
||||
<p class="text-xs text-gray-400">Updated {new Date(r.updated_at).toLocaleDateString()}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if selectedRepo}
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold mb-3">Commits: {selectedRepo}</h3>
|
||||
<div class="space-y-2">
|
||||
{#each commits.slice(0, 20) as c}
|
||||
<div class="border-b pb-2">
|
||||
<p class="text-sm">{c.commit?.message?.split("\n")[0]}</p>
|
||||
<p class="text-xs text-gray-400">{c.commit?.author?.name} - {new Date(c.commit?.author?.date).toLocaleString()}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let termEl;
|
||||
|
||||
onMount(async () => {
|
||||
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(() => {
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
|
||||
<h2 class="text-2xl font-bold mb-4">Terminal</h2>
|
||||
<div bind:this={termEl} class="h-[calc(100vh-8rem)] rounded-lg overflow-hidden"></div>
|
||||
Reference in New Issue
Block a user