feat: implement drives, backend and frontend
This commit is contained in:
1 parent
6ad0a461e4
commit
3283b77ab9
12 files changed
+781
-93
No files matched your search
@@ -137,6 +137,23 @@ export async function newToken () {
|
||||
|
||||
// --- Communication ---
|
||||
|
||||
export async function webFetch (path, options = {}) {
|
||||
const tk = loginToken || await loginTokenDeferred.promise;
|
||||
const response = await fetch(`${backendUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${tk}`,
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Response Error: ${response.status}\n${errorText}`);
|
||||
}
|
||||
// TODO: Check content-type before parsing
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function sendAction(action, body = {}, callback = null) {
|
||||
if (socket?.readyState !== WebSocket.OPEN) {
|
||||
return Promise.reject(new Error("WebSocket is not open. Cannot send action."));
|
||||
@@ -358,6 +375,39 @@ export function invalidateCache({ chats=false, agents=false } = {}) {
|
||||
}
|
||||
|
||||
|
||||
// --- Drive Storage (REST) ---
|
||||
// Drive endpoints are plain HTTP routes instead of WebSocket actions,
|
||||
// so they authenticate with the bearer token in the Authorization header.
|
||||
|
||||
export function getDrives () {
|
||||
return webFetch('/files');
|
||||
}
|
||||
|
||||
export function getDriveFiles (driveName) {
|
||||
return webFetch(`/files/${driveName}`);
|
||||
}
|
||||
|
||||
export function uploadDriveFile (driveName, file) {
|
||||
const body = new FormData();
|
||||
body.append('file', file, file.name);
|
||||
return webFetch(`/files/${driveName}`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
// Anchor downloads can't set headers, so the token travels as a query parameter
|
||||
export function driveFileUrl (driveName, filename) {
|
||||
return `${backendUrl}/files/${driveName}/${filename}?token=${loginToken}`;
|
||||
}
|
||||
|
||||
export function deleteDriveFile (driveName, filename) {
|
||||
const body = new FormData();
|
||||
return webFetch(`/files/${driveName}/${filename}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// --- Connection ---
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
<RouterLink to="/user" class="nav-item">User</RouterLink>
|
||||
<RouterLink to="/models" class="nav-item">Manage Models</RouterLink>
|
||||
<RouterLink to="/remotes" class="nav-item">Remotes</RouterLink>
|
||||
<RouterLink to="/drives" class="nav-item">Drives</RouterLink>
|
||||
<RouterLink to="/runs" class="nav-item">Agent Runs</RouterLink>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -61,6 +61,11 @@ const router = createRouter({
|
||||
path: `/remotes`,
|
||||
component: () => import('../views/Remotes.vue')
|
||||
},
|
||||
{
|
||||
name: 'drives',
|
||||
path: `/drives`,
|
||||
component: () => import('../views/Drives.vue')
|
||||
},
|
||||
objectView('chat', () => import('../views/Chat.vue')),
|
||||
objectView('agent', () => import('../views/Agent.vue')),
|
||||
staticView('user', () => import('../views/User.vue')),
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import TitleBar from '../components/TitleBar.vue';
|
||||
import * as backend from '../backend';
|
||||
import * as ui from '../ui';
|
||||
|
||||
// Folder structure is implicit in the stored file names ("docs/api/readme.md"):
|
||||
// the tree is a pure function of the file list, rebuilt on every load. Which
|
||||
// folders are open lives on the drive (drive.open), never inside tree nodes,
|
||||
// which is why a rebuild never loses it.
|
||||
// drive: { id, name, expanded, loading, error, root, count, open: Set<dirPath> }
|
||||
// dir node: { type: 'dir', name, path, children: Map<name, node> }
|
||||
// file node: { type: 'file', name, path, id, size, mime, created }
|
||||
const drives = ref([]);
|
||||
const loading = ref(false);
|
||||
const fileInput = ref(null);
|
||||
let uploadTarget = null; // { drive, dirPath } while the file picker is open
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
drives.value = (await backend.getDrives()).map(d => ({
|
||||
id: d.id,
|
||||
name: d.name || `drive-${d.id}`,
|
||||
expanded: false, loading: false, error: null,
|
||||
root: null, count: 0, open: new Set(),
|
||||
}));
|
||||
} catch (error) {
|
||||
ui.alert('Failed to load drives: ' + (error.message || error));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const dirNode = (name, path) => ({ type: 'dir', name, path, children: new Map() });
|
||||
|
||||
function buildTree (files) {
|
||||
const root = dirNode('', '');
|
||||
for (const file of files) {
|
||||
const parts = String(file.name).split('/').filter(Boolean);
|
||||
if (!parts.length) continue;
|
||||
let node = root;
|
||||
for (let i = 0; i < parts.length - 1; i++) { // create the parent folders as needed
|
||||
const path = parts.slice(0, i + 1).join('/');
|
||||
let dir = node.children.get(parts[i]);
|
||||
if (!dir || dir.type !== 'dir') node.children.set(parts[i], dir = dirNode(parts[i], path));
|
||||
node = dir;
|
||||
}
|
||||
const name = parts.at(-1);
|
||||
if (node.children.get(name)?.type !== 'dir') { // a folder wins on a name clash
|
||||
node.children.set(name, { type: 'file', name, path: parts.join('/'), id: file.id, size: file.size, mime: file.mime, created: file.created });
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
// Folders first, then natural name order ("a2" before "a10").
|
||||
const byTypeThenName = (a, b) =>
|
||||
a.type === b.type
|
||||
? a.name.localeCompare(b.name, undefined, { numeric: true })
|
||||
: a.type === 'dir' ? -1 : 1;
|
||||
|
||||
// Flattens the currently open part of a drive's tree into display rows.
|
||||
function rows (drive) {
|
||||
const out = [];
|
||||
const walk = (node, depth) => {
|
||||
for (const child of [...node.children.values()].sort(byTypeThenName)) {
|
||||
out.push({ node: child, depth });
|
||||
if (child.type === 'dir' && drive.open.has(child.path)) walk(child, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(drive.root, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadFiles (drive) {
|
||||
drive.loading = true;
|
||||
drive.error = null;
|
||||
try {
|
||||
const files = await backend.getDriveFiles(drive.name);
|
||||
drive.root = buildTree(files);
|
||||
drive.count = files.length;
|
||||
} catch (error) {
|
||||
drive.error = error.message || String(error);
|
||||
} finally {
|
||||
drive.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDrive (drive) {
|
||||
drive.expanded = !drive.expanded;
|
||||
if (drive.expanded && !drive.root && !drive.loading) await loadFiles(drive); // lazy first load
|
||||
}
|
||||
|
||||
function onRowClick (drive, node) {
|
||||
if (node.type === 'dir') { // toggle folder
|
||||
drive.open.has(node.path) ? drive.open.delete(node.path) : drive.open.add(node.path);
|
||||
} else {
|
||||
window.open(backend.driveFileUrl(drive.name, node.path), '_blank'); // served with its mime type
|
||||
}
|
||||
}
|
||||
|
||||
async function remove (drive, node) {
|
||||
if (!await ui.confirm(`Delete "${node.path}" from drive "${drive.name}"?`)) return;
|
||||
try {
|
||||
await backend.deleteDriveFile(drive.name, node.path);
|
||||
await loadFiles(drive); // open folders survive: only the tree is replaced
|
||||
} catch (error) {
|
||||
ui.alert('Delete failed: ' + (error.message || error));
|
||||
}
|
||||
}
|
||||
|
||||
function pickUpload (drive, dirPath = '') {
|
||||
uploadTarget = { drive, dirPath };
|
||||
fileInput.value.value = ''; // allow picking the same file again
|
||||
fileInput.value.click();
|
||||
}
|
||||
|
||||
// prefixes('docs/api') -> ['docs', 'docs/api']
|
||||
const prefixes = path => path.split('/').filter(Boolean).map((part, i, all) => all.slice(0, i + 1).join('/'));
|
||||
|
||||
async function onFilesPicked (event) {
|
||||
const files = [...event.target.files];
|
||||
const { drive, dirPath } = uploadTarget ?? {};
|
||||
uploadTarget = null;
|
||||
if (!drive || !files.length) return;
|
||||
drive.expanded = true;
|
||||
for (const file of files) {
|
||||
const path = dirPath ? `${dirPath}/${file.name}` : file.name;
|
||||
try {
|
||||
await backend.uploadDriveFile(drive.name, file, path);
|
||||
} catch (error) {
|
||||
ui.alert(`Upload failed for "${path}": ` + (error.message || error));
|
||||
}
|
||||
}
|
||||
prefixes(dirPath).forEach(path => drive.open.add(path)); // reveal the destination folder
|
||||
await loadFiles(drive);
|
||||
}
|
||||
|
||||
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
function formatSize (bytes) {
|
||||
if (bytes == null) return '';
|
||||
let value = bytes, unit = 0;
|
||||
while (value >= 1024 && unit < SIZE_UNITS.length - 1) { value /= 1024; unit += 1; }
|
||||
return value < 10 && unit > 0 ? `${value.toFixed(1)} ${SIZE_UNITS[unit]}` : `${Math.round(value)} ${SIZE_UNITS[unit]}`;
|
||||
}
|
||||
|
||||
function formatDate (iso) {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso);
|
||||
return isNaN(date) ? iso : date.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TitleBar><h1>Drives</h1></TitleBar>
|
||||
|
||||
<section class="drives">
|
||||
<p v-if="loading" class="status">Loading drives…</p>
|
||||
<p v-else-if="!drives.length" class="status">No drives available.</p>
|
||||
|
||||
<article v-for="drive in drives" :key="drive.id" class="drive">
|
||||
<header class="row head" @click="toggleDrive(drive)">
|
||||
<span class="caret">{{ drive.expanded ? '▾' : '▸' }}</span>
|
||||
<span class="icon">🗄️</span>
|
||||
<b>{{ drive.name }}</b>
|
||||
<span v-if="drive.loading" class="muted">loading…</span>
|
||||
<span v-else-if="drive.count" class="muted">{{ drive.count }} files</span>
|
||||
<span class="spacer"></span>
|
||||
<button class="action" @click.stop="pickUpload(drive)">Upload</button>
|
||||
<button v-if="drive.root" class="action" @click.stop="loadFiles(drive)">Refresh</button>
|
||||
</header>
|
||||
|
||||
<p v-if="drive.expanded && drive.error" class="error">{{ drive.error }}</p>
|
||||
|
||||
<template v-if="drive.expanded && drive.root">
|
||||
<p v-if="!drive.count" class="status empty">This drive is empty. Use Upload to add files.</p>
|
||||
<div v-for="row in rows(drive)" :key="row.node.path" class="row"
|
||||
:style="{ paddingLeft: row.depth * 20 + 12 + 'px' }" @click="onRowClick(drive, row.node)">
|
||||
<span class="caret">{{ row.node.type === 'dir' ? (drive.open.has(row.node.path) ? '▾' : '▸') : '' }}</span>
|
||||
<span class="icon">{{ row.node.type === 'dir' ? '📁' : '📄' }}</span>
|
||||
<span class="name">{{ row.node.name }}</span>
|
||||
<span v-if="row.node.type === 'file'" class="muted meta">
|
||||
{{ formatSize(row.node.size) }} · {{ row.node.mime }} · {{ formatDate(row.node.created) }}
|
||||
</span>
|
||||
<span class="spacer"></span>
|
||||
<button v-if="row.node.type === 'dir'" class="action" @click.stop="pickUpload(drive, row.node.path)">Upload</button>
|
||||
<template v-else>
|
||||
<a class="action" :href="backend.driveFileUrl(drive.name, row.node.path)" :download="row.node.name" @click.stop>Download</a>
|
||||
<button class="action danger" @click.stop="remove(drive, row.node)">Delete</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<input ref="fileInput" type="file" multiple hidden @change="onFilesPicked" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drives {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 60rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.drive { border: 1px solid rgba(128, 128, 128, 0.35); border-radius: 0.4rem; }
|
||||
.status, .muted { color: gray; }
|
||||
.muted { font-size: 0.85em; }
|
||||
.error { color: #e05d5d; margin: 0.25rem 0.75rem; }
|
||||
.empty { padding: 0 0.75rem; }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.15rem 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.row:hover { background: rgba(128, 128, 128, 0.1); }
|
||||
.head { padding: 0.5rem 0.75rem; }
|
||||
.caret { flex-shrink: 0; }
|
||||
.name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.spacer { flex: 1; }
|
||||
.meta { white-space: nowrap; }
|
||||
|
||||
.action {
|
||||
font-size: 0.85em;
|
||||
padding: 0.1rem 0.55rem;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(128, 128, 128, 0.5);
|
||||
border-radius: 0.25rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover { background: rgba(128, 128, 128, 0.2); }
|
||||
.action.danger { color: #e05d5d; border-color: rgba(224, 93, 93, 0.6); }
|
||||
</style>
|
||||
Reference in new issue
Block a user