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
+958
View File
@@ -0,0 +1,958 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
api,
type HostRow,
type IdentityRow,
type FolderRow,
type ConnectionAuditRow,
} from "@/api";
import LoginForm from "@/components/LoginForm.vue";
import TabContent from "@/components/TabContent.vue";
interface TabItem {
id: string;
hostId: number;
label: string;
}
const loggedIn = ref(false);
const checking = ref(true);
const identities = ref<IdentityRow[]>([]);
const allHosts = ref<HostRow[]>([]);
const allFolders = ref<FolderRow[]>([]);
const browseFolders = ref<FolderRow[]>([]);
const browseHosts = ref<HostRow[]>([]);
const breadcrumb = ref<{ id: number; label: string }[]>([]);
const searchActive = ref(false);
const currentFolderId = ref<number | null>(null);
const searchQuery = ref("");
const tabs = ref<TabItem[]>([]);
const activeTabId = ref<string | null>(null);
const loadErr = ref("");
/** Narrow viewports: slide-over hosts panel; md+ sidebar stays visible */
const sidebarOpen = ref(true);
let searchDebounceTimer = 0;
const showIdentityForm = ref(false);
const showHostForm = ref(false);
const showFolderForm = ref(false);
const showEditHost = ref(false);
const showAuditLog = ref(false);
const auditLoading = ref(false);
const auditErr = ref("");
const auditRows = ref<ConnectionAuditRow[]>([]);
const newFolderLabel = ref("");
const identityForm = ref({
label: "",
auth_type: "password" as "password" | "publickey",
ssh_username: "",
password: "",
private_key: "",
key_passphrase: "",
});
const hostForm = ref({
label: "",
hostname: "",
port: 22,
identity_id: 0 as number,
jump_host_id: null as number | null,
});
const editHostForm = ref({
id: 0,
label: "",
hostname: "",
port: 22,
identity_id: 0,
folder_id: null as number | null,
jump_host_id: null as number | null,
});
function folderOptionLabel(id: number | null): string {
if (id == null) return "(root)";
const byId = new Map(allFolders.value.map((f) => [f.id, f]));
const parts: string[] = [];
let cur: number | null | undefined = id;
const guard = new Set<number>();
while (cur != null && !guard.has(cur)) {
guard.add(cur);
const f = byId.get(cur);
if (!f) break;
parts.unshift(f.label);
cur = f.parent_id;
}
return parts.join(" / ") || `#${id}`;
}
async function refreshBrowse() {
try {
const d = await api.browse(currentFolderId.value, searchQuery.value);
browseFolders.value = d.folders;
browseHosts.value = d.hosts;
breadcrumb.value = d.breadcrumb;
searchActive.value = d.search_active;
} catch (e) {
loadErr.value = e instanceof Error ? e.message : "Browse failed";
}
}
function onSearchInput() {
window.clearTimeout(searchDebounceTimer);
searchDebounceTimer = window.setTimeout(() => {
void refreshBrowse();
}, 300);
}
function goToFolder(id: number | null) {
currentFolderId.value = id;
searchQuery.value = "";
void refreshBrowse();
}
async function refreshData() {
loadErr.value = "";
try {
identities.value = await api.listIdentities();
allHosts.value = await api.listHosts();
allFolders.value = await api.listFoldersFlat();
if (!hostForm.value.identity_id && identities.value.length) {
hostForm.value.identity_id = identities.value[0].id;
}
await refreshBrowse();
} catch (e) {
loadErr.value = e instanceof Error ? e.message : "Failed to load data";
}
}
onMounted(async () => {
try {
const m = await api.me();
loggedIn.value = m.logged_in;
if (loggedIn.value) await refreshData();
} catch {
loggedIn.value = false;
} finally {
checking.value = false;
}
});
async function onLoggedIn() {
loggedIn.value = true;
await refreshData();
}
async function logout() {
await api.logout();
tabs.value = [];
activeTabId.value = null;
loggedIn.value = false;
}
function fmtDate(ts: string | null): string {
if (!ts) return "In progress";
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString();
}
function fmtDuration(totalSeconds: number | null): string {
if (totalSeconds == null) return "In progress";
const s = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}h ${m}m ${sec}s`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
}
async function openAuditLog() {
showAuditLog.value = true;
auditLoading.value = true;
auditErr.value = "";
try {
auditRows.value = await api.listConnectionAudit(250);
} catch (e) {
auditErr.value = e instanceof Error ? e.message : "Failed to load audit log";
} finally {
auditLoading.value = false;
}
}
function openTab(h: HostRow) {
const id = crypto.randomUUID();
tabs.value.push({ id, hostId: h.id, label: h.label });
activeTabId.value = id;
if (window.matchMedia("(max-width: 767px)").matches) {
sidebarOpen.value = false;
}
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value;
}
function closeTab(id: string) {
tabs.value = tabs.value.filter((t) => t.id !== id);
if (activeTabId.value === id) {
activeTabId.value = tabs.value.length ? tabs.value[tabs.value.length - 1].id : null;
}
}
async function submitIdentity() {
const f = identityForm.value;
const body: Record<string, unknown> = {
label: f.label.trim(),
auth_type: f.auth_type,
ssh_username: f.ssh_username.trim(),
};
if (f.auth_type === "password") body.password = f.password;
else {
body.private_key = f.private_key;
if (f.key_passphrase) body.key_passphrase = f.key_passphrase;
}
await api.createIdentity(body);
showIdentityForm.value = false;
identityForm.value = {
label: "",
auth_type: "password",
ssh_username: "",
password: "",
private_key: "",
key_passphrase: "",
};
await refreshData();
}
async function submitHost() {
const f = hostForm.value;
await api.createHost({
label: f.label.trim(),
hostname: f.hostname.trim(),
port: Number(f.port) || 22,
identity_id: f.identity_id,
folder_id: currentFolderId.value,
jump_host_id: f.jump_host_id,
});
showHostForm.value = false;
hostForm.value = {
label: "",
hostname: "",
port: 22,
identity_id: hostForm.value.identity_id,
jump_host_id: null,
};
await refreshData();
}
async function submitFolder() {
const l = newFolderLabel.value.trim();
if (!l) return;
await api.createFolder({ label: l, parent_id: currentFolderId.value });
newFolderLabel.value = "";
showFolderForm.value = false;
await refreshBrowse();
}
function openEditHost(h: HostRow) {
editHostForm.value = {
id: h.id,
label: h.label,
hostname: h.hostname,
port: h.port,
identity_id: h.identity_id,
folder_id: h.folder_id,
jump_host_id: h.jump_host_id,
};
showEditHost.value = true;
}
async function submitEditHost() {
const f = editHostForm.value;
await api.patchHost(f.id, {
label: f.label.trim(),
hostname: f.hostname.trim(),
port: Number(f.port) || 22,
identity_id: f.identity_id,
folder_id: f.folder_id,
jump_host_id: f.jump_host_id,
});
showEditHost.value = false;
allHosts.value = await api.listHosts();
await refreshBrowse();
}
async function deleteFolderRow(id: number) {
if (
!confirm(
"Delete this folder and all subfolders? Hosts in those folders become unfiled (root).",
)
)
return;
await api.deleteFolder(id);
if (currentFolderId.value === id) {
currentFolderId.value = null;
}
await refreshData();
}
async function deleteHostRow(id: number) {
if (!confirm("Remove this host entry?")) return;
await api.deleteHost(id);
allHosts.value = allHosts.value.filter((h) => h.id !== id);
tabs.value = tabs.value.filter((t) => t.hostId !== id);
if (!tabs.value.some((t) => t.id === activeTabId.value)) {
activeTabId.value = tabs.value.length
? tabs.value[tabs.value.length - 1].id
: null;
}
await refreshBrowse();
}
async function deleteIdentityRow(id: number) {
if (!confirm("Remove this identity? Hosts using it may break.")) return;
await api.deleteIdentity(id);
await refreshData();
}
</script>
<template>
<div v-if="checking" class="flex min-h-screen items-center justify-center text-slate-500">
Loading
</div>
<LoginForm v-else-if="!loggedIn" @logged-in="onLoggedIn" />
<div v-else class="flex h-screen min-h-0 flex-col bg-surface font-sans">
<header
class="flex shrink-0 items-center justify-between border-b border-slate-800 bg-surface-raised px-3 py-2 md:px-4"
>
<div class="flex min-w-0 items-center gap-2">
<button
type="button"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-slate-300 hover:bg-slate-800 hover:text-white"
aria-controls="hosts-sidebar"
:aria-expanded="sidebarOpen"
aria-label="Toggle hosts menu"
@click="toggleSidebar"
>
<span class="sr-only">Menu</span>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<span class="truncate text-sm font-semibold text-white">JDB-NET SSH</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
@click="openAuditLog"
>
Connection audit
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
@click="logout"
>
Sign out
</button>
</div>
</header>
<div class="relative flex min-h-0 flex-1">
<div
v-show="sidebarOpen"
class="fixed inset-0 top-14 z-30 bg-black/50 md:hidden"
aria-hidden="true"
@click="sidebarOpen = false"
/>
<aside
id="hosts-sidebar"
class="flex w-72 shrink-0 flex-col border-r border-slate-800 bg-surface-raised transition-all duration-200 ease-out max-md:fixed max-md:bottom-0 max-md:left-0 max-md:top-14 max-md:z-40 max-md:max-h-[calc(100dvh-3.5rem)] max-md:shadow-2xl md:relative"
:class="
sidebarOpen
? 'max-md:translate-x-0 md:translate-x-0 md:w-72 md:opacity-100'
: 'max-md:-translate-x-full md:-translate-x-full md:w-0 md:opacity-0 md:border-r-0 md:overflow-hidden'
"
>
<div class="border-b border-slate-800 p-3">
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">
Hosts
</div>
<input
v-model="searchQuery"
type="search"
placeholder="Search in this folder…"
class="mt-2 w-full rounded-lg border border-slate-700 bg-surface-overlay px-2 py-1.5 text-xs text-white placeholder:text-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
@input="onSearchInput"
>
<p
v-if="searchActive && searchQuery.trim()"
class="mt-1 text-[10px] text-slate-500"
>
Matches in
{{
currentFolderId == null
? "all folders"
: "this folder and below"
}}
</p>
<nav class="mt-2 flex flex-wrap items-center gap-1 text-[11px] text-slate-400">
<button
type="button"
class="rounded px-1.5 py-0.5 hover:bg-slate-800 hover:text-white"
@click="goToFolder(null)"
>
Home
</button>
<template v-for="c in breadcrumb" :key="c.id">
<span class="text-slate-600">/</span>
<button
type="button"
class="rounded px-1.5 py-0.5 hover:bg-slate-800 hover:text-white"
@click="goToFolder(c.id)"
>
{{ c.label }}
</button>
</template>
</nav>
<div class="mt-2 flex flex-wrap gap-2">
<button
type="button"
class="rounded bg-accent/20 px-2 py-1 text-xs text-accent hover:bg-accent/30"
@click="showHostForm = true"
>
Add host
</button>
<button
type="button"
class="rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
@click="showFolderForm = true"
>
New folder
</button>
<button
type="button"
class="rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
@click="showIdentityForm = true"
>
Identities
</button>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-2">
<p v-if="loadErr" class="mb-2 text-xs text-red-400">{{ loadErr }}</p>
<ul v-if="!searchActive" class="mb-3 space-y-1">
<li
v-for="f in browseFolders"
:key="'f' + f.id"
class="flex items-center justify-between gap-1 rounded-lg border border-slate-800 bg-surface-overlay px-2 py-1.5"
>
<button
type="button"
class="min-w-0 flex-1 truncate text-left text-sm text-accent hover:underline"
@click="goToFolder(f.id)"
>
📁 {{ f.label }}
</button>
<button
type="button"
class="shrink-0 text-[10px] text-red-400/80 hover:underline"
@click="deleteFolderRow(f.id)"
>
Del
</button>
</li>
</ul>
<ul class="space-y-1">
<li
v-for="h in browseHosts"
:key="'h' + h.id"
class="rounded-lg border border-slate-800 bg-surface-overlay p-2"
>
<button
type="button"
class="w-full text-left text-sm font-medium text-white hover:text-accent"
@click="openTab(h)"
>
{{ h.label }}
</button>
<div class="mt-0.5 truncate font-mono text-[10px] text-slate-500">
{{ h.hostname }}:{{ h.port }} · {{ h.identity_label }}
</div>
<p
v-if="h.jump_host_id"
class="mt-0.5 truncate text-[10px] text-slate-500"
>
via {{ h.jump_host_label || `#${h.jump_host_id}` }}
</p>
<p
v-if="searchActive"
class="mt-0.5 truncate text-[10px] text-slate-600"
>
{{ folderOptionLabel(h.folder_id) }}
</p>
<div class="mt-1 flex gap-2">
<button
type="button"
class="text-[10px] text-slate-400 hover:text-white hover:underline"
@click="openEditHost(h)"
>
Edit
</button>
<button
type="button"
class="text-[10px] text-red-400/80 hover:underline"
@click="deleteHostRow(h.id)"
>
Remove
</button>
</div>
</li>
</ul>
<p
v-if="!browseFolders.length && !browseHosts.length && !loadErr"
class="py-4 text-center text-xs text-slate-500"
>
{{
searchActive
? "No hosts match."
: "Empty folder — add a host or subfolder."
}}
</p>
</div>
<div class="border-t border-slate-800 p-2">
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">
Saved identities
</div>
<ul class="mt-1 max-h-32 overflow-auto text-xs text-slate-400">
<li
v-for="i in identities"
:key="i.id"
class="flex items-center justify-between gap-1 py-0.5"
>
<span class="truncate">{{ i.label }} ({{ i.auth_type }})</span>
<button
type="button"
class="shrink-0 text-red-400/70 hover:underline"
@click="deleteIdentityRow(i.id)"
>
×
</button>
</li>
</ul>
</div>
</aside>
<main class="flex min-h-0 min-w-0 flex-1 flex-col">
<div
v-if="!tabs.length"
class="flex flex-1 items-center justify-center text-sm text-slate-500"
>
Select a host to open a tab, or add one from the sidebar.
</div>
<template v-else>
<div
class="flex shrink-0 gap-1 overflow-x-auto border-b border-slate-800 bg-surface-raised px-2 pt-2"
>
<button
v-for="t in tabs"
:key="t.id"
type="button"
class="flex items-center gap-2 rounded-t-lg border border-b-0 px-3 py-2 text-sm"
:class="
t.id === activeTabId
? 'border-slate-700 bg-surface text-white'
: 'border-transparent bg-transparent text-slate-400 hover:text-white'
"
@click="activeTabId = t.id"
>
{{ t.label }}
<span
class="rounded px-1 text-slate-500 hover:bg-slate-800 hover:text-white"
@click.stop="closeTab(t.id)"
>×</span>
</button>
</div>
<div class="min-h-0 flex-1 p-2 md:p-3">
<div
v-for="t in tabs"
v-show="t.id === activeTabId"
:key="t.id"
class="h-full min-h-0"
>
<TabContent
:host-id="t.hostId"
:visible="t.id === activeTabId"
/>
</div>
</div>
</template>
</main>
</div>
<div
v-if="showAuditLog"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showAuditLog = false"
>
<div
class="max-h-[90vh] w-full max-w-4xl overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
>
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-white">Connection audit</h2>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
@click="showAuditLog = false"
>
Close
</button>
</div>
<p class="mt-1 text-xs text-slate-500">
Recent SSH sessions and how long they lasted.
</p>
<p v-if="auditErr" class="mt-3 text-xs text-red-400">{{ auditErr }}</p>
<p v-else-if="auditLoading" class="mt-3 text-xs text-slate-400">
Loading
</p>
<div v-else class="mt-4 overflow-x-auto">
<table class="min-w-full text-left text-xs">
<thead class="text-slate-500">
<tr class="border-b border-slate-800">
<th class="px-2 py-2 font-medium">Host</th>
<th class="px-2 py-2 font-medium">Address</th>
<th class="px-2 py-2 font-medium">Started</th>
<th class="px-2 py-2 font-medium">Ended</th>
<th class="px-2 py-2 font-medium">Duration</th>
</tr>
</thead>
<tbody>
<tr
v-for="r in auditRows"
:key="r.id"
class="border-b border-slate-900/80 text-slate-300"
>
<td class="px-2 py-2">
<div class="truncate">{{ r.host_label }}</div>
<div
v-if="r.jump_host_id"
class="truncate text-[10px] text-slate-500"
>
via #{{ r.jump_host_id }}
</div>
</td>
<td class="px-2 py-2 font-mono text-[11px]">
{{ r.hostname }}:{{ r.port }}
</td>
<td class="px-2 py-2">{{ fmtDate(r.started_at) }}</td>
<td class="px-2 py-2">{{ fmtDate(r.ended_at) }}</td>
<td class="px-2 py-2">{{ fmtDuration(r.duration_seconds) }}</td>
</tr>
<tr v-if="!auditRows.length">
<td class="px-2 py-4 text-center text-slate-500" colspan="5">
No connection sessions yet.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div
v-if="showIdentityForm"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showIdentityForm = false"
>
<form
class="max-h-[90vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
@submit.prevent="submitIdentity"
>
<h2 class="text-lg font-semibold text-white">New identity</h2>
<label class="mt-4 block text-xs uppercase text-slate-500">Label</label>
<input
v-model="identityForm.label"
required
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/>
<label class="mt-3 block text-xs uppercase text-slate-500">Auth</label>
<select
v-model="identityForm.auth_type"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<option value="password">Password</option>
<option value="publickey">SSH private key</option>
</select>
<label class="mt-3 block text-xs uppercase text-slate-500">SSH username</label>
<input
v-model="identityForm.ssh_username"
required
autocomplete="off"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/>
<template v-if="identityForm.auth_type === 'password'">
<label class="mt-3 block text-xs uppercase text-slate-500">Password</label>
<input
v-model="identityForm.password"
type="password"
required
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/>
</template>
<template v-else>
<label class="mt-3 block text-xs uppercase text-slate-500">Private key (PEM)</label>
<textarea
v-model="identityForm.private_key"
required
rows="6"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 font-mono text-xs"
/>
<label class="mt-3 block text-xs uppercase text-slate-500">Key passphrase (optional)</label>
<input
v-model="identityForm.key_passphrase"
type="password"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/>
</template>
<div class="mt-6 flex justify-end gap-2">
<button
type="button"
class="rounded-lg px-4 py-2 text-sm text-slate-400 hover:bg-slate-800"
@click="showIdentityForm = false"
>
Cancel
</button>
<button
type="submit"
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
>
Save
</button>
</div>
</form>
</div>
<div
v-if="showHostForm"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showHostForm = false"
>
<form
class="w-full max-w-md rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
@submit.prevent="submitHost"
>
<h2 class="text-lg font-semibold text-white">New host</h2>
<p class="mt-1 text-xs text-slate-500">
Folder: {{ folderOptionLabel(currentFolderId) }}
</p>
<label class="mt-4 block text-xs uppercase text-slate-500">Label</label>
<input
v-model="hostForm.label"
required
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/>
<label class="mt-3 block text-xs uppercase text-slate-500">Hostname</label>
<input
v-model="hostForm.hostname"
required
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/>
<label class="mt-3 block text-xs uppercase text-slate-500">Port</label>
<input
v-model.number="hostForm.port"
type="number"
min="1"
max="65535"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/>
<label class="mt-3 block text-xs uppercase text-slate-500">Identity</label>
<select
v-model.number="hostForm.identity_id"
required
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<option v-for="i in identities" :key="i.id" :value="i.id">
{{ i.label }} ({{ i.auth_type }})
</option>
</select>
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
<select
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
:value="hostForm.jump_host_id === null ? '' : String(hostForm.jump_host_id)"
@change="
hostForm.jump_host_id =
($event.target as HTMLSelectElement).value === ''
? null
: Number(($event.target as HTMLSelectElement).value)
"
>
<option value="">None (direct)</option>
<option v-for="h in allHosts" :key="h.id" :value="String(h.id)">
{{ h.label }} ({{ h.hostname }}:{{ h.port }})
</option>
</select>
<div class="mt-6 flex justify-end gap-2">
<button
type="button"
class="rounded-lg px-4 py-2 text-sm text-slate-400 hover:bg-slate-800"
@click="showHostForm = false"
>
Cancel
</button>
<button
type="submit"
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
:disabled="!identities.length"
>
Save
</button>
</div>
</form>
</div>
<div
v-if="showFolderForm"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showFolderForm = false"
>
<form
class="w-full max-w-sm rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
@submit.prevent="submitFolder"
>
<h2 class="text-lg font-semibold text-white">New folder</h2>
<p class="mt-1 text-xs text-slate-500">
Inside: {{ folderOptionLabel(currentFolderId) }}
</p>
<label class="mt-4 block text-xs uppercase text-slate-500">Name</label>
<input
v-model="newFolderLabel"
required
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<div class="mt-6 flex justify-end gap-2">
<button
type="button"
class="rounded-lg px-4 py-2 text-sm text-slate-400 hover:bg-slate-800"
@click="showFolderForm = false"
>
Cancel
</button>
<button
type="submit"
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
>
Create
</button>
</div>
</form>
</div>
<div
v-if="showEditHost"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
@click.self="showEditHost = false"
>
<form
class="max-h-[90vh] w-full max-w-md overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
@submit.prevent="submitEditHost"
>
<h2 class="text-lg font-semibold text-white">Edit host</h2>
<label class="mt-4 block text-xs uppercase text-slate-500">Label</label>
<input
v-model="editHostForm.label"
required
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<label class="mt-3 block text-xs uppercase text-slate-500">Hostname</label>
<input
v-model="editHostForm.hostname"
required
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<label class="mt-3 block text-xs uppercase text-slate-500">Port</label>
<input
v-model.number="editHostForm.port"
type="number"
min="1"
max="65535"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<label class="mt-3 block text-xs uppercase text-slate-500">Folder</label>
<select
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
:value="editHostForm.folder_id === null ? '' : String(editHostForm.folder_id)"
@change="
editHostForm.folder_id =
($event.target as HTMLSelectElement).value === ''
? null
: Number(($event.target as HTMLSelectElement).value)
"
>
<option value="">(root)</option>
<option
v-for="f in allFolders"
:key="f.id"
:value="String(f.id)"
>
{{ folderOptionLabel(f.id) }}
</option>
</select>
<label class="mt-3 block text-xs uppercase text-slate-500">Identity</label>
<select
v-model.number="editHostForm.identity_id"
required
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<option v-for="i in identities" :key="i.id" :value="i.id">
{{ i.label }} ({{ i.auth_type }})
</option>
</select>
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
<select
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
:value="editHostForm.jump_host_id === null ? '' : String(editHostForm.jump_host_id)"
@change="
editHostForm.jump_host_id =
($event.target as HTMLSelectElement).value === ''
? null
: Number(($event.target as HTMLSelectElement).value)
"
>
<option value="">None (direct)</option>
<option
v-for="h in allHosts.filter((x) => x.id !== editHostForm.id)"
:key="h.id"
:value="String(h.id)"
>
{{ h.label }} ({{ h.hostname }}:{{ h.port }})
</option>
</select>
<div class="mt-6 flex justify-end gap-2">
<button
type="button"
class="rounded-lg px-4 py-2 text-sm text-slate-400 hover:bg-slate-800"
@click="showEditHost = false"
>
Cancel
</button>
<button
type="submit"
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
>
Save
</button>
</div>
</form>
</div>
</div>
</template>
+281
View File
@@ -0,0 +1,281 @@
const jsonHeaders = { "Content-Type": "application/json" };
async function handle<T>(res: Response): Promise<T> {
if (res.status === 401) {
throw new Error("unauthorized");
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((data as { error?: string }).error || res.statusText);
}
return data as T;
}
function browseParams(folderId: number | null, q: string): string {
const p = new URLSearchParams();
if (folderId != null) p.set("folder_id", String(folderId));
else p.set("folder_id", "root");
const t = q.trim();
if (t) p.set("q", t);
return p.toString();
}
export const api = {
async me(): Promise<{ logged_in: boolean }> {
const res = await fetch("/api/me", { credentials: "include" });
return handle(res);
},
async login(username: string, password: string): Promise<void> {
const res = await fetch("/api/login", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ username, password }),
});
await handle(res);
},
async logout(): Promise<void> {
const res = await fetch("/api/logout", {
method: "POST",
credentials: "include",
});
await handle(res);
},
async browse(
folderId: number | null,
q: string,
): Promise<{
breadcrumb: { id: number; label: string }[];
folders: FolderRow[];
hosts: HostRow[];
search_active: boolean;
}> {
const res = await fetch(`/api/browse?${browseParams(folderId, q)}`, {
credentials: "include",
});
return handle(res);
},
async listHosts(): Promise<HostRow[]> {
const res = await fetch("/api/hosts", { credentials: "include" });
const d = await handle<{ items: HostRow[] }>(res);
return d.items;
},
async listFoldersFlat(): Promise<FolderRow[]> {
const res = await fetch("/api/folders", { credentials: "include" });
const d = await handle<{ items: FolderRow[] }>(res);
return d.items;
},
async createFolder(body: {
label: string;
parent_id?: number | null;
}): Promise<{ id: number }> {
const res = await fetch("/api/folders", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async deleteFolder(id: number): Promise<void> {
const res = await fetch(`/api/folders/${id}`, {
method: "DELETE",
credentials: "include",
});
await handle(res);
},
async listIdentities(): Promise<IdentityRow[]> {
const res = await fetch("/api/identities", { credentials: "include" });
const d = await handle<{ items: IdentityRow[] }>(res);
return d.items;
},
async createHost(body: {
label: string;
hostname: string;
port?: number;
identity_id: number;
folder_id?: number | null;
jump_host_id?: number | null;
}): Promise<{ id: number }> {
const res = await fetch("/api/hosts", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async patchHost(
id: number,
body: Partial<{
label: string;
hostname: string;
port: number;
identity_id: number;
folder_id: number | null;
jump_host_id: number | null;
}>,
): Promise<void> {
const res = await fetch(`/api/hosts/${id}`, {
method: "PATCH",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
await handle(res);
},
async deleteHost(id: number): Promise<void> {
const res = await fetch(`/api/hosts/${id}`, {
method: "DELETE",
credentials: "include",
});
await handle(res);
},
async createIdentity(body: Record<string, unknown>): Promise<{ id: number }> {
const res = await fetch("/api/identities", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async deleteIdentity(id: number): Promise<void> {
const res = await fetch(`/api/identities/${id}`, {
method: "DELETE",
credentials: "include",
});
await handle(res);
},
async listConnectionAudit(limit = 200): Promise<ConnectionAuditRow[]> {
const q = new URLSearchParams({ limit: String(limit) });
const res = await fetch(`/api/audit/connections?${q.toString()}`, {
credentials: "include",
});
const d = await handle<{ items: ConnectionAuditRow[] }>(res);
return d.items;
},
async sftpList(
connId: string,
path: string,
): Promise<{ path: string; entries: SftpEntry[] }> {
const res = await fetch(`/api/sftp/${connId}/list`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ path }),
});
return handle(res);
},
async sftpMkdir(connId: string, path: string): Promise<void> {
const res = await fetch(`/api/sftp/${connId}/mkdir`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ path }),
});
await handle(res);
},
async sftpRemove(connId: string, path: string): Promise<void> {
const res = await fetch(`/api/sftp/${connId}/remove`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ path }),
});
await handle(res);
},
async sftpRename(
connId: string,
oldPath: string,
newPath: string,
): Promise<void> {
const res = await fetch(`/api/sftp/${connId}/rename`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ old_path: oldPath, new_path: newPath }),
});
await handle(res);
},
async sftpUpload(connId: string, path: string, file: File): Promise<void> {
const fd = new FormData();
fd.set("path", path);
fd.set("file", file);
const res = await fetch(`/api/sftp/${connId}/upload`, {
method: "POST",
credentials: "include",
body: fd,
});
await handle(res);
},
sftpDownloadUrl(connId: string, path: string): string {
const q = new URLSearchParams({ path });
return `/api/sftp/${connId}/download?${q}`;
},
};
export interface FolderRow {
id: number;
label: string;
parent_id: number | null;
}
export interface HostRow {
id: number;
folder_id: number | null;
label: string;
hostname: string;
port: number;
identity_id: number;
jump_host_id: number | null;
jump_host_label?: string | null;
identity_label: string;
identity_auth_type: string;
folder_label?: string | null;
}
export interface IdentityRow {
id: number;
label: string;
auth_type: string;
}
export interface SftpEntry {
filename: string;
st_mode: number;
st_size: number;
st_mtime: number;
}
export interface ConnectionAuditRow {
id: number;
host_id: number | null;
host_label: string;
hostname: string;
port: number;
jump_host_id: number | null;
started_at: string;
ended_at: string | null;
duration_seconds: number | null;
}
+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>
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import "./style.css";
const app = createApp(App);
app.use(createPinia());
app.mount("#app");
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}
+16
View File
@@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html.dark,
html {
color-scheme: dark;
}
.xterm {
padding: 8px 0;
}
.xterm-viewport {
background-color: transparent !important;
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}