@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user