feat: initial commit
CI / Build and Push (push) Successful in 12s

This commit is contained in:
2026-04-17 12:12:31 +01:00
commit 8d8e9c052d
27 changed files with 5955 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { ref } from "vue";
import { api } from "@/api";
const emit = defineEmits<{ loggedIn: [] }>();
const username = ref("");
const password = ref("");
const err = ref("");
const busy = ref(false);
async function submit() {
err.value = "";
busy.value = true;
try {
await api.login(username.value.trim(), password.value);
emit("loggedIn");
} catch (e) {
err.value = e instanceof Error ? e.message : "Login failed";
} finally {
busy.value = false;
}
}
</script>
<template>
<div
class="flex min-h-screen items-center justify-center bg-surface p-6"
>
<div
class="w-full max-w-md rounded-xl border border-slate-800 bg-surface-raised p-8 shadow-xl"
>
<h1 class="font-sans text-2xl font-semibold tracking-tight text-white">
JDB-NET SSH
</h1>
<p class="mt-1 text-sm text-slate-400">
Sign in to manage connections and open terminals.
</p>
<form class="mt-8 space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Username</label>
<input
v-model="username"
type="text"
autocomplete="username"
class="w-full rounded-lg border border-slate-700 bg-surface-overlay px-3 py-2 text-sm text-white outline-none ring-accent focus:border-accent focus:ring-1"
required
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Password</label>
<input
v-model="password"
type="password"
autocomplete="current-password"
class="w-full rounded-lg border border-slate-700 bg-surface-overlay px-3 py-2 text-sm text-white outline-none ring-accent focus:border-accent focus:ring-1"
required
/>
</div>
<p v-if="err" class="text-sm text-red-400">{{ err }}</p>
<button
type="submit"
:disabled="busy"
class="w-full rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-slate-950 transition hover:bg-sky-400 disabled:opacity-50"
>
{{ busy ? "Signing in…" : "Sign in" }}
</button>
</form>
</div>
</div>
</template>
+258
View File
@@ -0,0 +1,258 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { api, type SftpEntry } from "@/api";
const props = defineProps<{ connId: string }>();
const path = ref("/");
const entries = ref<SftpEntry[]>([]);
const err = ref("");
const busy = ref(false);
const renameTarget = ref<SftpEntry | null>(null);
const newName = ref("");
function isDir(m: number): boolean {
return (m & 0o170000) === 0o040000;
}
function joinRemote(base: string, name: string): string {
if (base === "/") return `/${name}`.replace("//", "/");
return `${base.replace(/\/$/, "")}/${name}`;
}
async function load() {
err.value = "";
busy.value = true;
try {
const r = await api.sftpList(props.connId, path.value);
path.value = r.path;
entries.value = r.entries;
} catch (e) {
err.value = e instanceof Error ? e.message : "List failed";
} finally {
busy.value = false;
}
}
watch(
() => props.connId,
() => {
path.value = "/";
load();
},
{ immediate: true },
);
function enter(e: SftpEntry) {
const p = joinRemote(path.value, e.filename);
if (isDir(e.st_mode)) {
path.value = p;
load();
}
}
function parent() {
if (path.value === "/") return;
const parts = path.value.replace(/\/$/, "").split("/");
parts.pop();
path.value = parts.length ? parts.join("/") || "/" : "/";
load();
}
async function onUpload(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
err.value = "";
try {
await api.sftpUpload(props.connId, path.value, file);
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Upload failed";
}
}
async function downloadFile(e: SftpEntry) {
const p = joinRemote(path.value, e.filename);
err.value = "";
try {
const res = await fetch(api.sftpDownloadUrl(props.connId, p), {
credentials: "include",
});
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = e.filename;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
err.value = e instanceof Error ? e.message : "Download failed";
}
}
async function removeEntry(e: SftpEntry) {
if (!confirm(`Delete ${e.filename}?`)) return;
const p = joinRemote(path.value, e.filename);
err.value = "";
try {
await api.sftpRemove(props.connId, p);
await load();
} catch (err2) {
err.value = err2 instanceof Error ? err2.message : "Remove failed";
}
}
function startRename(e: SftpEntry) {
renameTarget.value = e;
newName.value = e.filename;
}
async function confirmRename() {
if (!renameTarget.value) return;
const oldP = joinRemote(path.value, renameTarget.value.filename);
const newP = joinRemote(path.value, newName.value.trim());
renameTarget.value = null;
err.value = "";
try {
await api.sftpRename(props.connId, oldP, newP);
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "Rename failed";
}
}
async function mkdir() {
const name = prompt("Folder name");
if (!name?.trim()) return;
const p = joinRemote(path.value, name.trim());
err.value = "";
try {
await api.sftpMkdir(props.connId, p);
await load();
} catch (e) {
err.value = e instanceof Error ? e.message : "mkdir failed";
}
}
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
</script>
<template>
<div class="flex min-h-0 flex-col bg-surface-raised font-sans text-sm">
<div class="border-b border-slate-800 px-3 py-2">
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">
SFTP
</div>
<div class="mt-1 truncate font-mono text-xs text-slate-300" :title="path">
{{ path }}
</div>
<div class="mt-2 flex flex-wrap gap-1">
<button
type="button"
class="rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
@click="parent"
>
Up
</button>
<button
type="button"
class="rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
@click="load"
>
Refresh
</button>
<button
type="button"
class="rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
@click="mkdir"
>
New folder
</button>
<label
class="cursor-pointer rounded bg-accent/20 px-2 py-1 text-xs text-accent hover:bg-accent/30"
>
Upload
<input type="file" class="hidden" @change="onUpload" />
</label>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-2">
<p v-if="err" class="mb-2 text-xs text-red-400">{{ err }}</p>
<p v-if="busy" class="text-xs text-slate-500">Loading</p>
<ul v-else class="space-y-0.5">
<li
v-for="e in entries"
:key="e.filename"
class="group flex items-center gap-2 rounded px-2 py-1 hover:bg-surface-overlay"
>
<button
type="button"
class="min-w-0 flex-1 truncate text-left font-mono text-xs"
:class="isDir(e.st_mode) ? 'text-accent' : 'text-slate-200'"
@click="enter(e)"
>
{{ isDir(e.st_mode) ? "📁" : "📄" }} {{ e.filename }}
</button>
<span class="shrink-0 text-[10px] text-slate-500">{{
isDir(e.st_mode) ? "" : fmtSize(e.st_size)
}}</span>
<button
v-if="!isDir(e.st_mode)"
type="button"
class="shrink-0 text-[10px] text-slate-500 opacity-0 group-hover:opacity-100 hover:text-accent"
@click="downloadFile(e)"
>
Get
</button>
<button
type="button"
class="shrink-0 text-[10px] text-slate-500 opacity-0 group-hover:opacity-100 hover:text-accent"
@click="startRename(e)"
>
Ren
</button>
<button
type="button"
class="shrink-0 text-[10px] text-red-400/80 opacity-0 group-hover:opacity-100"
@click="removeEntry(e)"
>
Del
</button>
</li>
</ul>
</div>
<div
v-if="renameTarget"
class="border-t border-slate-800 p-2"
>
<input
v-model="newName"
class="mb-2 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1 font-mono text-xs"
@keyup.enter="confirmRename"
/>
<div class="flex gap-2">
<button
type="button"
class="rounded bg-accent px-2 py-1 text-xs text-slate-950"
@click="confirmRename"
>
OK
</button>
<button
type="button"
class="rounded bg-slate-800 px-2 py-1 text-xs"
@click="renameTarget = null"
>
Cancel
</button>
</div>
</div>
</div>
</template>
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
import {
onMounted,
onUnmounted,
ref,
watch,
nextTick,
} from "vue";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import SftpPanel from "./SftpPanel.vue";
const props = defineProps<{
hostId: number;
visible: boolean;
}>();
const termEl = ref<HTMLElement | null>(null);
const status = ref("Connecting…");
const connId = ref<string | null>(null);
let ws: WebSocket | null = null;
let term: Terminal | null = null;
let fit: FitAddon | null = null;
let ro: ResizeObserver | null = null;
function wsUrl(hostId: number): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${location.host}/ws/terminal?host_id=${hostId}`;
}
function sendResize() {
if (!ws || ws.readyState !== WebSocket.OPEN || !term) return;
const dims = { cols: term.cols, rows: term.rows };
ws.send(JSON.stringify({ type: "resize", ...dims }));
}
function fitAndResize() {
if (!fit || !term || !props.visible) return;
try {
fit.fit();
sendResize();
} catch {
/* ignore */
}
}
onMounted(async () => {
await nextTick();
if (!termEl.value) return;
term = new Terminal({
cursorBlink: true,
fontFamily: "IBM Plex Mono, monospace",
fontSize: 14,
theme: {
background: "#0a0e12",
foreground: "#e2e8f0",
cursor: "#3d9aed",
},
});
fit = new FitAddon();
term.loadAddon(fit);
term.open(termEl.value);
fit.fit();
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
});
term.onResize(({ cols, rows }) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "resize", cols, rows }));
}
});
ro = new ResizeObserver(() => fitAndResize());
ro.observe(termEl.value);
ws = new WebSocket(wsUrl(props.hostId));
ws.binaryType = "arraybuffer";
ws.onopen = () => {
status.value = "Handshaking…";
sendResize();
};
ws.onmessage = (ev) => {
if (!term) return;
if (typeof ev.data === "string") {
try {
const o = JSON.parse(ev.data) as { type?: string; conn_id?: string };
if (o.type === "ready" && o.conn_id) {
connId.value = o.conn_id;
status.value = "";
fitAndResize();
term.focus();
return;
}
} catch {
/* fall through */
}
term.write(ev.data);
return;
}
const u8 = new Uint8Array(ev.data as ArrayBuffer);
term.write(new TextDecoder().decode(u8));
};
ws.onerror = () => {
status.value = "WebSocket error";
};
ws.onclose = () => {
if (!connId.value) {
status.value = "Disconnected";
} else {
status.value = "Session ended";
}
};
});
onUnmounted(() => {
ro?.disconnect();
ro = null;
ws?.close();
ws = null;
term?.dispose();
term = null;
fit = null;
});
watch(
() => props.visible,
async (v) => {
if (v) {
await nextTick();
fitAndResize();
term?.focus();
}
},
);
</script>
<template>
<div class="flex h-full min-h-0 gap-0">
<div class="relative min-h-0 min-w-0 flex-1 flex-col">
<div
v-if="status"
class="absolute left-3 top-2 z-10 rounded bg-black/70 px-2 py-1 font-mono text-xs text-amber-200"
>
{{ status }}
</div>
<div
ref="termEl"
class="h-full min-h-[320px] rounded-lg border border-slate-800 bg-[#0a0e12] p-1"
/>
</div>
<div
v-if="connId"
class="hidden w-80 shrink-0 flex-col border-l border-slate-800 md:flex"
>
<SftpPanel :conn-id="connId" />
</div>
<div
v-else
class="hidden w-72 shrink-0 items-center justify-center border-l border-slate-800 bg-surface-raised text-xs text-slate-500 md:flex"
>
SFTP unlocks when the shell session is ready.
</div>
</div>
</template>