1014 lines
41 KiB
Twig
1014 lines
41 KiB
Twig
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>vStash - Object Storage Management Console</title>
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #f8fafc;
|
|
color: #1a202c;
|
|
}
|
|
|
|
.container {
|
|
display: flex;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 250px;
|
|
background: #2d3748;
|
|
color: white;
|
|
padding: 1rem 0;
|
|
}
|
|
|
|
.sidebar h1 {
|
|
padding: 0 1rem 2rem;
|
|
font-size: 1.5rem;
|
|
border-bottom: 1px solid #4a5568;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.nav-item {
|
|
display: block;
|
|
padding: 0.75rem 1rem;
|
|
color: #e2e8f0;
|
|
text-decoration: none;
|
|
transition: background 0.2s;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.nav-item:hover, .nav-item.active {
|
|
background: #4a5568;
|
|
color: white;
|
|
}
|
|
|
|
.nav-item i {
|
|
width: 20px;
|
|
margin-right: 0.5rem;
|
|
}
|
|
|
|
.main-content {
|
|
flex: 1;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 2rem;
|
|
padding-bottom: 1rem;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.btn {
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
transition: all 0.2s;
|
|
text-decoration: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #3182ce;
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: #2c5282;
|
|
}
|
|
|
|
.btn-danger {
|
|
background: #e53e3e;
|
|
color: white;
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: #c53030;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: #718096;
|
|
color: white;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: #4a5568;
|
|
}
|
|
|
|
.btn-sm {
|
|
padding: 0.25rem 0.5rem;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.card {
|
|
background: white;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.card-header {
|
|
padding: 1rem;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
font-weight: 600;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.card-body {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.stat-card {
|
|
background: white;
|
|
padding: 1.5rem;
|
|
border-radius: 0.5rem;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-number {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
color: #3182ce;
|
|
display: block;
|
|
}
|
|
|
|
.stat-label {
|
|
color: #718096;
|
|
font-size: 0.875rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
.table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.table th,
|
|
.table td {
|
|
padding: 0.75rem;
|
|
text-align: left;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.table th {
|
|
background: #f7fafc;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.table tbody tr:hover {
|
|
background: #f7fafc;
|
|
}
|
|
|
|
.badge {
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.badge-success {
|
|
background: #c6f6d5;
|
|
color: #22543d;
|
|
}
|
|
|
|
.badge-danger {
|
|
background: #fed7d7;
|
|
color: #742a2a;
|
|
}
|
|
|
|
.modal {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.modal-content {
|
|
background: white;
|
|
border-radius: 0.5rem;
|
|
padding: 2rem;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.form-label {
|
|
display: block;
|
|
margin-bottom: 0.5rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.form-input,
|
|
.form-select {
|
|
width: 100%;
|
|
padding: 0.5rem;
|
|
border: 1px solid #d2d6dc;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.form-input:focus,
|
|
.form-select:focus {
|
|
outline: none;
|
|
border-color: #3182ce;
|
|
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.1);
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: #718096;
|
|
}
|
|
|
|
.error {
|
|
background: #fed7d7;
|
|
color: #742a2a;
|
|
padding: 1rem;
|
|
border-radius: 0.375rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.success {
|
|
background: #c6f6d5;
|
|
color: #22543d;
|
|
padding: 1rem;
|
|
border-radius: 0.375rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 3rem 1rem;
|
|
color: #718096;
|
|
}
|
|
|
|
.empty-state i {
|
|
font-size: 3rem;
|
|
margin-bottom: 1rem;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.search-box {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.search-input {
|
|
width: 100%;
|
|
max-width: 300px;
|
|
padding: 0.5rem 1rem;
|
|
border: 1px solid #d2d6dc;
|
|
border-radius: 0.375rem;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.fade-in {
|
|
animation: fadeIn 0.3s ease-in;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
[x-cloak] { display: none !important; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div x-data="consoleApp()" x-init="init()" class="container">
|
|
<!-- Sidebar -->
|
|
<nav class="sidebar">
|
|
<h1><i class="fas fa-cloud"></i> vStash</h1>
|
|
<a @click="setActiveSection('dashboard')" class="nav-item" :class="{ active: activeSection === 'dashboard' }">
|
|
<i class="fas fa-chart-bar"></i> Dashboard
|
|
</a>
|
|
<a @click="setActiveSection('credentials')" class="nav-item" :class="{ active: activeSection === 'credentials' }">
|
|
<i class="fas fa-key"></i> Credentials
|
|
</a>
|
|
<a @click="setActiveSection('buckets')" class="nav-item" :class="{ active: activeSection === 'buckets' }">
|
|
<i class="fas fa-folder"></i> Buckets
|
|
</a>
|
|
<a @click="setActiveSection('multipart')" class="nav-item" :class="{ active: activeSection === 'multipart' }">
|
|
<i class="fas fa-upload"></i> Multipart Uploads
|
|
</a>
|
|
<a @click="setActiveSection('presigned')" class="nav-item" :class="{ active: activeSection === 'presigned' }">
|
|
<i class="fas fa-link"></i> Presigned URLs
|
|
</a>
|
|
</nav>
|
|
|
|
<!-- Main Content -->
|
|
<main class="main-content">
|
|
<!-- Dashboard Section -->
|
|
<div x-show="activeSection === 'dashboard'" x-cloak class="fade-in">
|
|
<div class="header">
|
|
<h2><i class="fas fa-chart-bar"></i> Dashboard</h2>
|
|
<button @click="loadStats()" class="btn btn-secondary">
|
|
<i class="fas fa-sync-alt"></i> Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="loading" class="loading">
|
|
<i class="fas fa-spinner fa-spin fa-2x"></i>
|
|
<p>Loading statistics...</p>
|
|
</div>
|
|
|
|
<div x-show="!loading && stats" class="stats-grid">
|
|
<div class="stat-card">
|
|
<span class="stat-number" x-text="stats.credentials"></span>
|
|
<div class="stat-label">Credentials</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-number" x-text="stats.buckets"></span>
|
|
<div class="stat-label">Buckets</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-number" x-text="stats.objects"></span>
|
|
<div class="stat-label">Objects</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-number" x-text="stats.total_storage_human"></span>
|
|
<div class="stat-label">Total Storage</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-number" x-text="stats.active_multipart_uploads"></span>
|
|
<div class="stat-label">Active Uploads</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-number" x-text="stats.active_presigned_urls"></span>
|
|
<div class="stat-label">Active URLs</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Credentials Section -->
|
|
<div x-show="activeSection === 'credentials'" x-cloak class="fade-in">
|
|
<div class="header">
|
|
<h2><i class="fas fa-key"></i> Access Credentials</h2>
|
|
<button @click="showCreateCredentialModal = true" class="btn btn-primary">
|
|
<i class="fas fa-plus"></i> Create Credential
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="message" x-text="message" :class="messageType === 'error' ? 'error' : 'success'"></div>
|
|
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div x-show="loading" class="loading">Loading credentials...</div>
|
|
|
|
<template x-if="!loading && credentials.length === 0">
|
|
<div class="empty-state">
|
|
<i class="fas fa-key"></i>
|
|
<h3>No credentials found</h3>
|
|
<p>Create your first access credential to get started.</p>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="!loading && credentials.length > 0">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Access Key</th>
|
|
<th>User Name</th>
|
|
<th>Status</th>
|
|
<th>Buckets</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="credential in credentials" :key="credential.id">
|
|
<tr>
|
|
<td>
|
|
<code x-text="credential.access_key"></code>
|
|
</td>
|
|
<td x-text="credential.user_name || '-'"></td>
|
|
<td>
|
|
<span class="badge" :class="credential.is_active ? 'badge-success' : 'badge-danger'"
|
|
x-text="credential.is_active ? 'Active' : 'Inactive'"></span>
|
|
</td>
|
|
<td x-text="credential.bucket_count"></td>
|
|
<td x-text="credential.created_at"></td>
|
|
<td>
|
|
<div class="actions">
|
|
<button @click="viewCredential(credential)" class="btn btn-sm btn-secondary">
|
|
<i class="fas fa-eye"></i>
|
|
</button>
|
|
<button @click="deleteCredential(credential.id)" class="btn btn-sm btn-danger">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Buckets Section -->
|
|
<div x-show="activeSection === 'buckets'" x-cloak class="fade-in">
|
|
<div class="header">
|
|
<h2><i class="fas fa-folder"></i> Storage Buckets</h2>
|
|
<button @click="showCreateBucketModal = true; loadCredentials()" class="btn btn-primary">
|
|
<i class="fas fa-plus"></i> Create Bucket
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="message" x-text="message" :class="messageType === 'error' ? 'error' : 'success'"></div>
|
|
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div x-show="loading" class="loading">Loading buckets...</div>
|
|
|
|
<template x-if="!loading && buckets.length === 0">
|
|
<div class="empty-state">
|
|
<i class="fas fa-folder"></i>
|
|
<h3>No buckets found</h3>
|
|
<p>Create your first bucket to start storing objects.</p>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="!loading && buckets.length > 0">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Region</th>
|
|
<th>Owner</th>
|
|
<th>Objects</th>
|
|
<th>Size</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="bucket in buckets" :key="bucket.name">
|
|
<tr>
|
|
<td>
|
|
<a @click="viewBucket(bucket.name)" class="text-blue-600 cursor-pointer" x-text="bucket.name"></a>
|
|
</td>
|
|
<td x-text="bucket.region"></td>
|
|
<td x-text="bucket.owner"></td>
|
|
<td x-text="bucket.object_count"></td>
|
|
<td x-text="bucket.total_size_human"></td>
|
|
<td x-text="bucket.created_at"></td>
|
|
<td>
|
|
<div class="actions">
|
|
<button @click="viewBucket(bucket.name)" class="btn btn-sm btn-secondary">
|
|
<i class="fas fa-eye"></i>
|
|
</button>
|
|
<button @click="deleteBucket(bucket.name)" class="btn btn-sm btn-danger">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bucket Details Section -->
|
|
<div x-show="activeSection === 'bucket-detail'" x-cloak class="fade-in">
|
|
<div class="header">
|
|
<div>
|
|
<button @click="setActiveSection('buckets')" class="btn btn-secondary">
|
|
<i class="fas fa-arrow-left"></i> Back
|
|
</button>
|
|
<h2 style="display: inline-block; margin-left: 1rem;">
|
|
<i class="fas fa-folder"></i> <span x-text="selectedBucket?.name"></span>
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="selectedBucket" class="card">
|
|
<div class="card-header">
|
|
Objects
|
|
<div>
|
|
<input x-model="objectSearch" @input="filterObjects()"
|
|
placeholder="Search objects..." class="search-input">
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div x-show="loading" class="loading">Loading objects...</div>
|
|
|
|
<template x-if="!loading && filteredObjects.length === 0">
|
|
<div class="empty-state">
|
|
<i class="fas fa-file"></i>
|
|
<h3>No objects found</h3>
|
|
<p>This bucket is empty or no objects match your search.</p>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="!loading && filteredObjects.length > 0">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Key</th>
|
|
<th>Size</th>
|
|
<th>Type</th>
|
|
<th>Modified</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="object in filteredObjects" :key="object.key">
|
|
<tr>
|
|
<td x-text="object.key"></td>
|
|
<td x-text="object.size_human"></td>
|
|
<td x-text="object.content_type"></td>
|
|
<td x-text="object.created_at"></td>
|
|
<td>
|
|
<button @click="deleteObject(selectedBucket.name, object.key)" class="btn btn-sm btn-danger">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Presigned URLs Section -->
|
|
<div x-show="activeSection === 'presigned'" x-cloak class="fade-in">
|
|
<div class="header">
|
|
<h2><i class="fas fa-link"></i> Presigned URLs</h2>
|
|
<button @click="showCreatePresignedModal = true; loadBuckets(); loadCredentials()" class="btn btn-primary">
|
|
<i class="fas fa-plus"></i> Create URL
|
|
</button>
|
|
</div>
|
|
|
|
<div x-show="message" x-text="message" :class="messageType === 'error' ? 'error' : 'success'"></div>
|
|
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div x-show="loading" class="loading">Loading URLs...</div>
|
|
|
|
<template x-if="!loading && presignedUrls.length === 0">
|
|
<div class="empty-state">
|
|
<i class="fas fa-link"></i>
|
|
<h3>No presigned URLs found</h3>
|
|
<p>Create a presigned URL to share temporary access.</p>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="!loading && presignedUrls.length > 0">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Bucket</th>
|
|
<th>Object</th>
|
|
<th>Method</th>
|
|
<th>Expires</th>
|
|
<th>URL</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template x-for="p in presignedUrls" :key="p.url">
|
|
<tr>
|
|
<td x-text="p.bucket_name"></td>
|
|
<td x-text="p.object_key"></td>
|
|
<td x-text="p.method"></td>
|
|
<td x-text="p.expires_at"></td>
|
|
<td>
|
|
<input class="form-input" :value="p.url" readonly style="font-size: 0.8rem;">
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Credential Modal -->
|
|
<div x-show="showCreateCredentialModal" x-cloak class="modal" @click.self="showCreateCredentialModal = false">
|
|
<div class="modal-content">
|
|
<h3 style="margin-bottom: 1rem;">Create New Credential</h3>
|
|
<form @submit.prevent="createCredential()">
|
|
<div class="form-group">
|
|
<label class="form-label">User Name</label>
|
|
<input x-model="newCredential.user_name" type="text" class="form-input" placeholder="Optional">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Access Key</label>
|
|
<input x-model="newCredential.access_key" type="text" class="form-input"
|
|
placeholder="Leave blank to auto-generate">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Secret Key</label>
|
|
<input x-model="newCredential.secret_key" type="text" class="form-input"
|
|
placeholder="Leave blank to auto-generate">
|
|
</div>
|
|
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
|
<button type="button" @click="showCreateCredentialModal = false" class="btn btn-secondary">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" class="btn btn-primary">
|
|
Create
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Bucket Modal -->
|
|
<div x-show="showCreateBucketModal" x-cloak class="modal" @click.self="showCreateBucketModal = false">
|
|
<div class="modal-content">
|
|
<h3 style="margin-bottom: 1rem;">Create New Bucket</h3>
|
|
<form @submit.prevent="createBucket()">
|
|
<div class="form-group">
|
|
<label class="form-label">Bucket Name</label>
|
|
<input x-model="newBucket.name" type="text" class="form-input"
|
|
placeholder="my-bucket-name" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Owner</label>
|
|
<select x-model="newBucket.owner_id" class="form-select" required>
|
|
<option value="">Select owner...</option>
|
|
<template x-for="credential in credentials" :key="credential.id">
|
|
<option :value="credential.id" x-text="credential.user_name || credential.access_key"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Region</label>
|
|
<select x-model="newBucket.region" class="form-select">
|
|
<option value="us-east-1">us-east-1</option>
|
|
<option value="us-west-2">us-west-2</option>
|
|
<option value="eu-west-1">eu-west-1</option>
|
|
</select>
|
|
</div>
|
|
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
|
<button type="button" @click="showCreateBucketModal = false" class="btn btn-secondary">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" class="btn btn-primary">
|
|
Create
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Create Presigned URL Modal -->
|
|
<div x-show="showCreatePresignedModal" x-cloak class="modal" @click.self="showCreatePresignedModal = false">
|
|
<div class="modal-content">
|
|
<h3 style="margin-bottom: 1rem;">Create Presigned URL</h3>
|
|
<form @submit.prevent="createPresigned()">
|
|
<div class="form-group">
|
|
<label class="form-label">Bucket Name</label>
|
|
<select x-model="newPresigned.bucket_name" class="form-select" required>
|
|
<option value="">Select bucket...</option>
|
|
<template x-for="bucket in buckets" :key="bucket.name">
|
|
<option :value="bucket.name" x-text="bucket.name"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Object Key</label>
|
|
<input x-model="newPresigned.object_key" type="text" class="form-input" placeholder="path/to/file.txt" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Method</label>
|
|
<select x-model="newPresigned.method" class="form-select">
|
|
<option value="GET">GET</option>
|
|
<option value="PUT">PUT</option>
|
|
<option value="POST">POST</option>
|
|
<option value="DELETE">DELETE</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Expires In (seconds)</label>
|
|
<input x-model="newPresigned.expires_in" type="number" class="form-input" min="1" max="604800" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Access Key</label>
|
|
<select x-model="newPresigned.access_key" class="form-select" required>
|
|
<option value="">Select credential...</option>
|
|
<template x-for="credential in credentials" :key="credential.id">
|
|
<option :value="credential.access_key" x-text="credential.user_name || credential.access_key"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
|
<button type="button" @click="showCreatePresignedModal = false" class="btn btn-secondary">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" class="btn btn-primary">
|
|
Create
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
function consoleApp() {
|
|
return {
|
|
activeSection: 'dashboard',
|
|
loading: false,
|
|
message: '',
|
|
messageType: 'success',
|
|
|
|
// Data
|
|
stats: null,
|
|
credentials: [],
|
|
buckets: [],
|
|
selectedBucket: null,
|
|
filteredObjects: [],
|
|
objectSearch: '',
|
|
presignedUrls: [],
|
|
|
|
// Modals
|
|
showCreateCredentialModal: false,
|
|
showCreateBucketModal: false,
|
|
showCreatePresignedModal: false,
|
|
|
|
// Forms
|
|
newCredential: {
|
|
user_name: '',
|
|
access_key: '',
|
|
secret_key: ''
|
|
},
|
|
newBucket: {
|
|
name: '',
|
|
owner_id: '',
|
|
region: 'us-east-1'
|
|
},
|
|
newPresigned: {
|
|
bucket_name: '',
|
|
object_key: '',
|
|
method: 'GET',
|
|
expires_in: 3600,
|
|
access_key: ''
|
|
},
|
|
|
|
init() {
|
|
this.loadStats();
|
|
},
|
|
|
|
setActiveSection(section) {
|
|
this.activeSection = section;
|
|
this.message = '';
|
|
|
|
switch(section) {
|
|
case 'dashboard':
|
|
this.loadStats();
|
|
break;
|
|
case 'credentials':
|
|
this.loadCredentials();
|
|
break;
|
|
case 'buckets':
|
|
this.loadBuckets();
|
|
break;
|
|
case 'presigned':
|
|
this.loadPresignedUrls();
|
|
break;
|
|
}
|
|
},
|
|
|
|
async apiCall(url, options = {}) {
|
|
try {
|
|
const response = await fetch(`/api${url}`, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...options.headers
|
|
},
|
|
...options
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
this.showMessage('API Error: ' + error.message, 'error');
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
showMessage(message, type = 'success') {
|
|
this.message = message;
|
|
this.messageType = type;
|
|
setTimeout(() => {
|
|
this.message = '';
|
|
}, 5000);
|
|
},
|
|
|
|
async loadStats() {
|
|
this.loading = true;
|
|
try {
|
|
this.stats = await this.apiCall('/stats');
|
|
} catch (error) {
|
|
console.error('Failed to load stats:', error);
|
|
}
|
|
this.loading = false;
|
|
},
|
|
|
|
async loadCredentials() {
|
|
this.loading = true;
|
|
try {
|
|
const response = await this.apiCall('/credentials');
|
|
this.credentials = response.credentials;
|
|
} catch (error) {
|
|
console.error('Failed to load credentials:', error);
|
|
}
|
|
this.loading = false;
|
|
},
|
|
|
|
async loadBuckets() {
|
|
this.loading = true;
|
|
try {
|
|
const response = await this.apiCall('/buckets');
|
|
this.buckets = response.buckets;
|
|
} catch (error) {
|
|
console.error('Failed to load buckets:', error);
|
|
}
|
|
this.loading = false;
|
|
},
|
|
|
|
async createCredential() {
|
|
try {
|
|
await this.apiCall('/credentials', {
|
|
method: 'POST',
|
|
body: JSON.stringify(this.newCredential)
|
|
});
|
|
|
|
this.showMessage('Credential created successfully!');
|
|
this.showCreateCredentialModal = false;
|
|
this.newCredential = { user_name: '', access_key: '', secret_key: '' };
|
|
this.loadCredentials();
|
|
} catch (error) {
|
|
console.error('Failed to create credential:', error);
|
|
}
|
|
},
|
|
|
|
async createBucket() {
|
|
try {
|
|
await this.apiCall('/buckets', {
|
|
method: 'POST',
|
|
body: JSON.stringify(this.newBucket)
|
|
});
|
|
|
|
this.showMessage('Bucket created successfully!');
|
|
this.showCreateBucketModal = false;
|
|
this.newBucket = { name: '', owner_id: '', region: 'us-east-1' };
|
|
this.loadBuckets();
|
|
} catch (error) {
|
|
console.error('Failed to create bucket:', error);
|
|
}
|
|
},
|
|
|
|
async loadPresignedUrls() {
|
|
this.loading = true;
|
|
try {
|
|
const response = await this.apiCall('/presigned-urls');
|
|
this.presignedUrls = response.urls;
|
|
} catch (error) {
|
|
console.error('Failed to load presigned URLs:', error);
|
|
}
|
|
this.loading = false;
|
|
},
|
|
|
|
async createPresigned() {
|
|
try {
|
|
const payload = { ...this.newPresigned };
|
|
const urlData = await this.apiCall('/presigned-urls', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
this.showMessage('Presigned URL created');
|
|
this.showCreatePresignedModal = false;
|
|
this.newPresigned = { bucket_name: '', object_key: '', method: 'GET', expires_in: 3600, access_key: '' };
|
|
this.presignedUrls.unshift({
|
|
bucket_name: payload.bucket_name,
|
|
object_key: payload.object_key,
|
|
method: payload.method,
|
|
expires_at: new Date(Date.now() + payload.expires_in * 1000).toISOString().slice(0,19).replace('T',' '),
|
|
url: urlData.url
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to create presigned URL:', error);
|
|
}
|
|
},
|
|
|
|
async deleteCredential(id) {
|
|
if (!confirm('Are you sure you want to delete this credential?')) return;
|
|
|
|
try {
|
|
await this.apiCall(`/credentials/${id}`, { method: 'DELETE' });
|
|
this.showMessage('Credential deleted successfully!');
|
|
this.loadCredentials();
|
|
} catch (error) {
|
|
console.error('Failed to delete credential:', error);
|
|
}
|
|
},
|
|
|
|
async deleteBucket(name) {
|
|
if (!confirm('Are you sure you want to delete this bucket?')) return;
|
|
|
|
try {
|
|
await this.apiCall(`/buckets/${name}`, { method: 'DELETE' });
|
|
this.showMessage('Bucket deleted successfully!');
|
|
this.loadBuckets();
|
|
} catch (error) {
|
|
console.error('Failed to delete bucket:', error);
|
|
}
|
|
},
|
|
|
|
async viewBucket(bucketName) {
|
|
this.loading = true;
|
|
try {
|
|
this.selectedBucket = await this.apiCall(`/buckets/${bucketName}`);
|
|
this.filteredObjects = this.selectedBucket.objects || [];
|
|
this.activeSection = 'bucket-detail';
|
|
} catch (error) {
|
|
console.error('Failed to load bucket details:', error);
|
|
}
|
|
this.loading = false;
|
|
},
|
|
|
|
filterObjects() {
|
|
if (!this.selectedBucket) return;
|
|
|
|
this.filteredObjects = this.selectedBucket.objects.filter(obj =>
|
|
obj.key.toLowerCase().includes(this.objectSearch.toLowerCase())
|
|
);
|
|
},
|
|
|
|
async deleteObject(bucketName, objectKey) {
|
|
if (!confirm('Are you sure you want to delete this object?')) return;
|
|
|
|
try {
|
|
await this.apiCall(`/buckets/${bucketName}/objects/${encodeURIComponent(objectKey)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
this.showMessage('Object deleted successfully!');
|
|
this.viewBucket(bucketName);
|
|
} catch (error) {
|
|
console.error('Failed to delete object:', error);
|
|
}
|
|
},
|
|
|
|
async viewCredential(credential) {
|
|
// Could open a modal with full credential details
|
|
alert(`Access Key: ${credential.access_key}\nUser: ${credential.user_name || 'N/A'}\nStatus: ${credential.is_active ? 'Active' : 'Inactive'}`);
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html> |