Redo the Admin portal for better UI/UX and more functionality

This commit is contained in:
Kohaku-Blueleaf
2025-10-18 18:06:07 +08:00
parent 5ac05e9efb
commit 945b542e59
20 changed files with 3527 additions and 250 deletions

View File

@@ -9,10 +9,12 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.12.2",
"chart.js": "^4.5.1",
"dayjs": "^1.11.18",
"element-plus": "^2.11.4",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1"
},
"devDependencies": {
@@ -346,6 +348,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.6.tgz",
@@ -684,6 +692,7 @@
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -1209,6 +1218,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.4",
"@vue/compiler-core": "3.5.22",
@@ -1598,6 +1608,19 @@
"node": ">= 0.4"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -2487,13 +2510,15 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -3015,6 +3040,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3413,6 +3439,7 @@
"integrity": "sha512-eSiiRJmovt8qDJkGyZuLnbxAOAdie6NCmmd0NkTC0RJI9duiSBTfr8X2mBYJOUFzxQa2USaHmL99J9uMxkjCyw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/runtime": "0.92.0",
"fdir": "^6.5.0",
@@ -3507,6 +3534,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3519,6 +3547,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22",
@@ -3535,6 +3564,16 @@
}
}
},
"node_modules/vue-chartjs": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-flow-layout": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/vue-flow-layout/-/vue-flow-layout-0.2.0.tgz",

View File

@@ -12,10 +12,12 @@
},
"dependencies": {
"axios": "^1.12.2",
"chart.js": "^4.5.1",
"dayjs": "^1.11.18",
"element-plus": "^2.11.4",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.1"
},
"devDependencies": {

View File

@@ -9,8 +9,11 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
AdminLayout: typeof import('./components/AdminLayout.vue')['default']
ChartCard: typeof import('./components/ChartCard.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElContainer: typeof import('element-plus/es')['ElContainer']
@@ -29,16 +32,19 @@ declare module 'vue' {
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElResult: typeof import('element-plus/es')['ElResult']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
FileTree: typeof import('./components/FileTree.vue')['default']
GlobalSearch: typeof import('./components/GlobalSearch.vue')['default']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
QuotaManager: typeof import('./components/QuotaManager.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']

View File

@@ -1,20 +1,30 @@
<script setup>
import { ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAdminStore } from "@/stores/admin";
import { useThemeStore } from "@/stores/theme";
import { ElMessage } from "element-plus";
import GlobalSearch from "@/components/GlobalSearch.vue";
const router = useRouter();
const route = useRoute();
const adminStore = useAdminStore();
const themeStore = useThemeStore();
const globalSearchRef = ref(null);
function handleLogout() {
adminStore.logout();
ElMessage.success("Logged out successfully");
router.push("/login");
}
function openGlobalSearch() {
if (globalSearchRef.value) {
globalSearchRef.value.openDialog();
}
}
const menuItems = [
{ path: "/", label: "Dashboard", icon: "i-carbon-dashboard" },
{ path: "/users", label: "Users", icon: "i-carbon-user-multiple" },
@@ -22,7 +32,16 @@ const menuItems = [
{ path: "/repositories", label: "Repositories", icon: "i-carbon-data-base" },
{ path: "/commits", label: "Commits", icon: "i-carbon-version" },
{ path: "/storage", label: "Storage", icon: "i-carbon-data-volume" },
{ path: "/quotas", label: "Quotas", icon: "i-carbon-meter" },
{
path: "/QuotaOverview",
label: "Quota Overview",
icon: "i-carbon-meter",
},
{
path: "/DatabaseViewer",
label: "Database",
icon: "i-carbon-data-table",
},
];
</script>
@@ -69,6 +88,13 @@ const menuItems = [
</div>
<div class="header-actions">
<el-button @click="openGlobalSearch" class="search-button">
<div class="i-carbon-search text-lg" />
<span class="ml-2 hidden sm:inline">Search</span>
<el-tag size="small" effect="plain" class="ml-2 hidden md:inline">
Ctrl+K
</el-tag>
</el-button>
<el-button circle @click="themeStore.toggle()" class="mr-2">
<div v-if="themeStore.isDark" class="i-carbon-moon text-lg" />
<div v-else class="i-carbon-asleep text-lg" />
@@ -84,53 +110,51 @@ const menuItems = [
<slot />
</el-main>
</el-container>
<!-- Global Search Modal -->
<GlobalSearch ref="globalSearchRef" />
</el-container>
</template>
<style scoped>
.admin-layout {
min-height: 100vh;
background-color: var(--bg-base);
}
.sidebar {
background-color: white;
border-right: 1px solid #e5e7eb;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.05);
}
html.dark .sidebar {
background-color: #1f1f1f;
border-right-color: #374151;
background-color: var(--bg-elevated);
border-right: 1px solid var(--border-default);
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.sidebar-header {
display: flex;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e5e7eb;
padding: 24px 20px;
border-bottom: 1px solid var(--border-light);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
html.dark .sidebar-header {
border-bottom-color: #374151;
.sidebar-header h2 {
color: white !important;
}
.sidebar-menu {
border-right: none;
background-color: var(--bg-elevated) !important;
}
.header {
background-color: white;
border-bottom: 1px solid #e5e7eb;
background-color: var(--bg-elevated);
border-bottom: 1px solid var(--border-default);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
html.dark .header {
background-color: #1f1f1f;
border-bottom-color: #374151;
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.header-title {
@@ -140,14 +164,22 @@ html.dark .header {
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.search-button {
border-color: var(--border-default);
background-color: var(--bg-hover);
transition: all 0.2s ease;
}
.search-button:hover {
border-color: var(--color-info);
background-color: var(--bg-active);
}
.main-content {
background-color: #f5f5f5;
background-color: var(--bg-base);
min-height: calc(100vh - 60px);
}
html.dark .main-content {
background-color: #0a0a0a;
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup>
import { ref, watch, onMounted } from "vue";
import { Line } from "vue-chartjs";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from "chart.js";
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
);
const props = defineProps({
title: {
type: String,
required: true,
},
labels: {
type: Array,
required: true,
},
datasets: {
type: Array,
required: true,
},
height: {
type: Number,
default: 200,
},
});
const chartData = ref({
labels: props.labels,
datasets: props.datasets,
});
const chartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "bottom",
},
title: {
display: false,
},
tooltip: {
mode: "index",
intersect: false,
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
},
},
},
interaction: {
mode: "nearest",
axis: "x",
intersect: false,
},
});
// Watch for prop changes
watch(
() => [props.labels, props.datasets],
() => {
chartData.value = {
labels: props.labels,
datasets: props.datasets,
};
},
{ deep: true },
);
</script>
<template>
<el-card class="chart-card">
<template #header>
<span class="font-bold">{{ title }}</span>
</template>
<div :style="{ height: height + 'px' }">
<Line :data="chartData" :options="chartOptions" />
</div>
</el-card>
</template>
<style scoped>
.chart-card {
height: 100%;
}
</style>

View File

@@ -0,0 +1,195 @@
<script setup>
import { ref, watch, onMounted } from "vue";
import { getRepositoryFiles, formatBytes } from "@/utils/api";
import { ElMessage } from "element-plus";
const props = defineProps({
repoType: {
type: String,
required: true,
},
namespace: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
token: {
type: String,
required: true,
},
});
const files = ref([]);
const loading = ref(false);
const selectedRef = ref("main");
async function loadFiles() {
loading.value = true;
try {
const response = await getRepositoryFiles(
props.token,
props.repoType,
props.namespace,
props.name,
selectedRef.value,
);
files.value = response.files || [];
} catch (error) {
console.error("Failed to load files:", error);
ElMessage.error(
error.response?.data?.detail?.error || "Failed to load repository files",
);
} finally {
loading.value = false;
}
}
function getFileIcon(path) {
const ext = path.split(".").pop().toLowerCase();
const iconMap = {
// Models
safetensors: "i-carbon-model",
bin: "i-carbon-model",
pt: "i-carbon-model",
pth: "i-carbon-model",
ckpt: "i-carbon-model",
onnx: "i-carbon-model",
gguf: "i-carbon-model",
// Config/JSON
json: "i-carbon-code",
yaml: "i-carbon-code",
yml: "i-carbon-code",
toml: "i-carbon-code",
cfg: "i-carbon-code",
// Text/Markdown
md: "i-carbon-document",
txt: "i-carbon-document",
rst: "i-carbon-document",
// Python
py: "i-carbon-logo-python",
// Archives
zip: "i-carbon-archive",
tar: "i-carbon-archive",
gz: "i-carbon-archive",
// Default
default: "i-carbon-document",
};
return iconMap[ext] || iconMap.default;
}
function truncateSHA(sha) {
return sha ? sha.substring(0, 16) + "..." : "-";
}
onMounted(() => {
loadFiles();
});
watch(selectedRef, () => {
loadFiles();
});
</script>
<template>
<div class="file-tree-container">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold">Branch/Ref:</span>
<el-input
v-model="selectedRef"
placeholder="main"
style="width: 200px"
size="small"
>
<template #prepend>
<div class="i-carbon-branch" />
</template>
</el-input>
<el-button size="small" @click="loadFiles" :icon="'Refresh'">
Refresh
</el-button>
</div>
<div class="text-sm text-gray-500">{{ files.length }} files</div>
</div>
<el-table
:data="files"
v-loading="loading"
stripe
style="width: 100%"
max-height="500"
>
<el-table-column label="Path" min-width="300">
<template #default="{ row }">
<div class="flex items-center gap-2">
<div :class="getFileIcon(row.path)" class="text-gray-600" />
<span class="font-mono text-sm">{{ row.path }}</span>
<el-tag v-if="row.is_lfs" type="warning" size="small" effect="plain">
LFS
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="Size" width="120" align="right">
<template #default="{ row }">
<span class="font-mono text-sm">{{ formatBytes(row.size) }}</span>
</template>
</el-table-column>
<el-table-column label="Versions" width="100" align="center">
<template #default="{ row }">
<el-tag type="info" size="small">
{{ row.version_count }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="SHA256" width="200">
<template #default="{ row }">
<code
v-if="row.sha256"
class="text-xs text-gray-600 dark:text-gray-400"
>
{{ truncateSHA(row.sha256) }}
</code>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
</el-table>
<div class="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded">
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Total Files:</span>
<span class="font-semibold">{{ files.length }}</span>
</div>
<div class="flex justify-between text-sm mt-1">
<span class="text-gray-600 dark:text-gray-400">LFS Files:</span>
<span class="font-semibold">
{{ files.filter((f) => f.is_lfs).length }}
</span>
</div>
<div class="flex justify-between text-sm mt-1">
<span class="text-gray-600 dark:text-gray-400">Total Size:</span>
<span class="font-semibold">
{{ formatBytes(files.reduce((sum, f) => sum + f.size, 0)) }}
</span>
</div>
</div>
</div>
</template>
<style scoped>
.file-tree-container {
width: 100%;
}
</style>

View File

@@ -0,0 +1,588 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRouter } from "vue-router";
import { useAdminStore } from "@/stores/admin";
import { globalSearch } from "@/utils/api";
import { ElMessage } from "element-plus";
const router = useRouter();
const adminStore = useAdminStore();
const dialogVisible = ref(false);
const searchQuery = ref("");
const searchResults = ref(null);
const loading = ref(false);
const searchDebounceTimer = ref(null);
const selectedIndex = ref(0);
// Computed flat list of all results for keyboard navigation
const flatResults = computed(() => {
if (!searchResults.value) return [];
const results = [];
// Add users
if (searchResults.value.results.users) {
searchResults.value.results.users.forEach((user) => {
results.push({
type: "user",
data: user,
label: user.username,
sublabel: user.email,
});
});
}
// Add repositories
if (searchResults.value.results.repositories) {
searchResults.value.results.repositories.forEach((repo) => {
results.push({
type: "repo",
data: repo,
label: repo.full_id,
sublabel: `${repo.repo_type}${repo.owner_username}`,
});
});
}
// Add commits
if (searchResults.value.results.commits) {
searchResults.value.results.commits.forEach((commit) => {
results.push({
type: "commit",
data: commit,
label: commit.message,
sublabel: `${commit.username}${commit.commit_id.substring(0, 8)}`,
});
});
}
return results;
});
const hasResults = computed(() => {
return flatResults.value.length > 0;
});
function openDialog() {
dialogVisible.value = true;
searchQuery.value = "";
searchResults.value = null;
selectedIndex.value = 0;
}
function closeDialog() {
dialogVisible.value = false;
searchQuery.value = "";
searchResults.value = null;
}
async function performSearch() {
if (!searchQuery.value || searchQuery.value.length < 2) {
searchResults.value = null;
return;
}
loading.value = true;
try {
searchResults.value = await globalSearch(
adminStore.token,
searchQuery.value,
["users", "repos", "commits"],
10,
);
selectedIndex.value = 0; // Reset selection
} catch (error) {
console.error("Search failed:", error);
ElMessage.error("Search failed. Please try again.");
} finally {
loading.value = false;
}
}
function handleSearchInput() {
// Clear existing timer
if (searchDebounceTimer.value) {
clearTimeout(searchDebounceTimer.value);
}
// Set new timer - wait 300ms after last input before searching
searchDebounceTimer.value = setTimeout(() => {
performSearch();
}, 300);
}
function handleKeyDown(event) {
if (!hasResults.value) return;
if (event.key === "ArrowDown") {
event.preventDefault();
selectedIndex.value = Math.min(
selectedIndex.value + 1,
flatResults.value.length - 1,
);
} else if (event.key === "ArrowUp") {
event.preventDefault();
selectedIndex.value = Math.max(selectedIndex.value - 1, 0);
} else if (event.key === "Enter") {
event.preventDefault();
if (flatResults.value[selectedIndex.value]) {
handleSelectResult(flatResults.value[selectedIndex.value]);
}
}
}
function handleSelectResult(result) {
closeDialog();
switch (result.type) {
case "user":
router.push(`/users`);
// Could expand to open user detail modal in the future
ElMessage.success(`Navigate to user: ${result.data.username}`);
break;
case "repo":
router.push(`/repositories`);
ElMessage.success(`Navigate to repository: ${result.data.full_id}`);
break;
case "commit":
router.push(`/commits`);
ElMessage.success(`Navigate to commits`);
break;
}
}
function getIcon(type) {
switch (type) {
case "user":
return "i-carbon-user";
case "repo":
return "i-carbon-data-base";
case "commit":
return "i-carbon-version";
default:
return "i-carbon-search";
}
}
function getTypeColor(type) {
switch (type) {
case "user":
return "primary";
case "repo":
return "success";
case "commit":
return "warning";
default:
return "info";
}
}
// Keyboard shortcut handler
function handleGlobalKeyDown(event) {
// Ctrl+K or Cmd+K
if ((event.ctrlKey || event.metaKey) && event.key === "k") {
event.preventDefault();
openDialog();
}
// Escape to close
if (event.key === "Escape" && dialogVisible.value) {
closeDialog();
}
}
onMounted(() => {
window.addEventListener("keydown", handleGlobalKeyDown);
});
onUnmounted(() => {
window.removeEventListener("keydown", handleGlobalKeyDown);
if (searchDebounceTimer.value) {
clearTimeout(searchDebounceTimer.value);
}
});
// Watch dialog visibility to focus input
watch(dialogVisible, (newVal) => {
if (newVal) {
setTimeout(() => {
const input = document.querySelector(".global-search-input input");
if (input) input.focus();
}, 100);
}
});
// Expose openDialog for parent components
defineExpose({ openDialog });
</script>
<template>
<el-dialog
v-model="dialogVisible"
width="600px"
:show-close="false"
class="global-search-dialog"
>
<div class="search-container">
<!-- Search Input -->
<el-input
v-model="searchQuery"
placeholder="Search users, repositories, commits... (Type to search)"
size="large"
clearable
@input="handleSearchInput"
@keydown="handleKeyDown"
class="global-search-input"
>
<template #prefix>
<div class="i-carbon-search text-xl text-gray-400" />
</template>
<template #suffix>
<div class="flex items-center gap-2">
<span
v-if="loading"
class="i-carbon-circle-dash animate-spin text-gray-400"
/>
<el-tag size="small" effect="plain">Ctrl+K</el-tag>
</div>
</template>
</el-input>
<!-- Results -->
<div v-if="searchQuery && searchQuery.length >= 2" class="results-container">
<div v-if="loading" class="text-center py-8 text-gray-500">
<div class="i-carbon-circle-dash animate-spin text-2xl mb-2" />
<p>Searching...</p>
</div>
<div v-else-if="!hasResults" class="text-center py-8 text-gray-500">
<div class="i-carbon-search-locate text-3xl mb-2" />
<p>No results found for "{{ searchQuery }}"</p>
</div>
<div v-else class="results-list">
<!-- Users Section -->
<div
v-if="
searchResults?.results?.users &&
searchResults.results.users.length > 0
"
class="result-section"
>
<div class="section-header">
<div class="i-carbon-user text-blue-600" />
<span>Users ({{ searchResults.results.users.length }})</span>
</div>
<div
v-for="(user, idx) in searchResults.results.users"
:key="`user-${user.id}`"
class="result-item"
:class="{
selected:
flatResults[selectedIndex]?.type === 'user' &&
flatResults[selectedIndex]?.data.id === user.id,
}"
@click="
handleSelectResult({
type: 'user',
data: user,
label: user.username,
sublabel: user.email,
})
"
>
<div class="i-carbon-user text-blue-600" />
<div class="result-content">
<div class="result-label">{{ user.username }}</div>
<div class="result-sublabel">{{ user.email }}</div>
</div>
<el-tag
v-if="user.email_verified"
type="success"
size="small"
effect="plain"
>
Verified
</el-tag>
</div>
</div>
<!-- Repositories Section -->
<div
v-if="
searchResults?.results?.repositories &&
searchResults.results.repositories.length > 0
"
class="result-section"
>
<div class="section-header">
<div class="i-carbon-data-base text-green-600" />
<span
>Repositories ({{ searchResults.results.repositories.length }})</span
>
</div>
<div
v-for="repo in searchResults.results.repositories"
:key="`repo-${repo.id}`"
class="result-item"
:class="{
selected:
flatResults[selectedIndex]?.type === 'repo' &&
flatResults[selectedIndex]?.data.id === repo.id,
}"
@click="
handleSelectResult({
type: 'repo',
data: repo,
label: repo.full_id,
sublabel: `${repo.repo_type} • ${repo.owner_username}`,
})
"
>
<div class="i-carbon-data-base text-green-600" />
<div class="result-content">
<div class="result-label">{{ repo.full_id }}</div>
<div class="result-sublabel">
{{ repo.repo_type }} • {{ repo.owner_username }}
</div>
</div>
<el-tag v-if="repo.private" type="warning" size="small" effect="plain">
Private
</el-tag>
</div>
</div>
<!-- Commits Section -->
<div
v-if="
searchResults?.results?.commits &&
searchResults.results.commits.length > 0
"
class="result-section"
>
<div class="section-header">
<div class="i-carbon-version text-orange-600" />
<span>Commits ({{ searchResults.results.commits.length }})</span>
</div>
<div
v-for="commit in searchResults.results.commits"
:key="`commit-${commit.id}`"
class="result-item"
:class="{
selected:
flatResults[selectedIndex]?.type === 'commit' &&
flatResults[selectedIndex]?.data.id === commit.id,
}"
@click="
handleSelectResult({
type: 'commit',
data: commit,
label: commit.message,
sublabel: `${commit.username} • ${commit.commit_id.substring(0, 8)}`,
})
"
>
<div class="i-carbon-version text-orange-600" />
<div class="result-content">
<div class="result-label">{{ commit.message }}</div>
<div class="result-sublabel">
{{ commit.username}} • {{ commit.commit_id.substring(0, 8) }} •
{{ commit.repo_full_id }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Hint -->
<div v-else class="hint-text">
<p>Type at least 2 characters to search</p>
<p class="text-xs mt-2">
<kbd></kbd> <kbd></kbd> to navigate <kbd>Enter</kbd> to select
<kbd>Esc</kbd> to close
</p>
</div>
</div>
</el-dialog>
</template>
<style scoped>
.global-search-dialog :deep(.el-dialog) {
margin-top: 10vh;
border-radius: 12px;
background-color: var(--bg-card);
border: 1px solid var(--border-default);
box-shadow: var(--shadow-lg);
}
.global-search-dialog :deep(.el-dialog__header) {
display: none;
}
.global-search-dialog :deep(.el-dialog__body) {
padding: 0;
background-color: var(--bg-card);
}
.search-container {
padding: 20px;
}
.global-search-input {
margin-bottom: 16px;
}
.global-search-input :deep(.el-input__wrapper) {
background-color: var(--bg-hover);
border-color: var(--border-default);
transition: all 0.2s ease;
}
.global-search-input :deep(.el-input__wrapper:hover) {
border-color: var(--color-info);
}
.global-search-input :deep(.el-input__wrapper.is-focus) {
border-color: var(--color-info);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.results-container {
max-height: 400px;
overflow-y: auto;
}
.results-container::-webkit-scrollbar {
width: 8px;
}
.results-container::-webkit-scrollbar-track {
background: var(--bg-hover);
border-radius: 4px;
}
.results-container::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 4px;
}
.results-container::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
.results-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.result-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.8px;
background-color: var(--bg-hover);
border-radius: 6px;
margin-bottom: 4px;
}
.result-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.result-item:hover {
background-color: var(--bg-hover);
border-color: var(--border-light);
transform: translateX(4px);
}
.result-item.selected {
background-color: rgba(59, 130, 246, 0.1);
border-color: var(--color-info);
}
.result-content {
flex: 1;
min-width: 0;
}
.result-label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.result-sublabel {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hint-text {
text-align: center;
padding: 40px 20px;
color: var(--text-secondary);
}
.hint-text p:first-child {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 8px;
}
kbd {
display: inline-block;
padding: 3px 8px;
font-size: 12px;
line-height: 1.4;
font-weight: 500;
color: var(--text-secondary);
background-color: var(--bg-hover);
border: 1px solid var(--border-default);
border-radius: 4px;
margin: 0 2px;
box-shadow: 0 2px 0 var(--border-default);
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -6,6 +6,7 @@ import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import "element-plus/theme-chalk/dark/css-vars.css";
import "uno.css";
import "./styles/colors.css";
import "./style.css";
import App from "./App.vue";

View File

@@ -0,0 +1,653 @@
<script setup>
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import AdminLayout from "@/components/AdminLayout.vue";
import { useAdminStore } from "@/stores/admin";
import {
listDatabaseTables,
getDatabaseQueryTemplates,
executeDatabaseQuery,
} from "@/utils/api";
import { ElMessage } from "element-plus";
const router = useRouter();
const adminStore = useAdminStore();
const tables = ref([]);
const templates = ref([]);
const queryText = ref("");
const queryResults = ref(null);
const loadingTables = ref(false);
const executing = ref(false);
const selectedTable = ref(null);
// PostgreSQL reserved keywords that need quoting
const reservedKeywords = ["user", "session", "commit", "table", "index", "group", "order"];
function checkAuth() {
if (!adminStore.token) {
router.push("/login");
return false;
}
return true;
}
async function loadTables() {
if (!checkAuth()) return;
loadingTables.value = true;
try {
const response = await listDatabaseTables(adminStore.token);
tables.value = response.tables || [];
} catch (error) {
console.error("Failed to load tables:", error);
ElMessage.error(
error.response?.data?.detail?.error || "Failed to load database tables",
);
} finally {
loadingTables.value = false;
}
}
async function loadTemplates() {
if (!checkAuth()) return;
try {
const response = await getDatabaseQueryTemplates(adminStore.token);
templates.value = response.templates || [];
} catch (error) {
console.error("Failed to load templates:", error);
}
}
async function executeQuery() {
if (!checkAuth()) return;
if (!queryText.value || queryText.value.trim().length === 0) {
ElMessage.warning("Please enter a SQL query");
return;
}
executing.value = true;
queryResults.value = null;
try {
const result = await executeDatabaseQuery(adminStore.token, queryText.value);
queryResults.value = result;
// Debug logging
console.log("Query results:", {
columns: result.columns,
columnCount: result.columns.length,
rowCount: result.count,
firstRow: result.rows[0],
});
if (result.truncated) {
ElMessage.warning(
"Results truncated to 1000 rows. Please add LIMIT to your query.",
);
} else {
ElMessage.success(
`Query executed: ${result.count} rows, ${result.columns.length} columns`,
);
}
} catch (error) {
console.error("Query execution failed:", error);
ElMessage.error(
error.response?.data?.detail?.error || "Query execution failed",
);
} finally {
executing.value = false;
}
}
function loadTemplate(template) {
queryText.value = template.sql;
ElMessage.success(`Loaded template: ${template.name}`);
}
function selectTable(table) {
selectedTable.value = table;
// Quote table name to handle reserved keywords (e.g., "user")
queryText.value = `SELECT * FROM "${table.name}" LIMIT 100;`;
}
function isReservedKeyword(tableName) {
return reservedKeywords.includes(tableName.toLowerCase());
}
function exportCSV() {
if (!queryResults.value || !queryResults.value.rows) {
ElMessage.warning("No results to export");
return;
}
const { columns, rows } = queryResults.value;
// Build CSV
const csv = [
columns.join(","), // Header
...rows.map((row) => columns.map((col) => JSON.stringify(row[col] ?? "")).join(",")),
].join("\n");
// Download
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `query_results_${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);
ElMessage.success("Results exported to CSV");
}
function exportJSON() {
if (!queryResults.value || !queryResults.value.rows) {
ElMessage.warning("No results to export");
return;
}
const json = JSON.stringify(queryResults.value.rows, null, 2);
// Download
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `query_results_${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
ElMessage.success("Results exported to JSON");
}
onMounted(() => {
loadTables();
loadTemplates();
});
</script>
<template>
<AdminLayout>
<div class="page-container">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">
Database Viewer
</h1>
<el-alert type="warning" :closable="false" show-icon>
<span class="text-sm">Read-only mode - Only SELECT queries allowed</span>
</el-alert>
</div>
<div class="database-viewer-container">
<div class="sidebar-section">
<!-- Tables List -->
<el-card class="mb-4">
<template #header>
<div class="flex items-center gap-2">
<div class="i-carbon-table text-blue-600" />
<span class="font-bold">Tables</span>
</div>
</template>
<div v-loading="loadingTables" class="tables-list">
<div
v-for="table in tables"
:key="table.name"
class="table-item"
:class="{ selected: selectedTable?.name === table.name }"
@click="selectTable(table)"
>
<div class="flex items-center gap-2">
<div class="i-carbon-data-table text-gray-600 dark:text-gray-400" />
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-mono text-sm font-semibold">
{{ table.name }}
</span>
<el-tag
v-if="isReservedKeyword(table.name)"
type="warning"
size="small"
effect="plain"
>
use quotes
</el-tag>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ table.column_count }} columns
</div>
</div>
</div>
</div>
</div>
</el-card>
<!-- Query Templates -->
<el-card>
<template #header>
<div class="flex items-center gap-2">
<div class="i-carbon-template text-green-600" />
<span class="font-bold">Templates</span>
</div>
</template>
<div class="templates-list">
<div
v-for="template in templates"
:key="template.name"
class="template-item"
@click="loadTemplate(template)"
>
<div class="font-semibold text-sm">{{ template.name }}</div>
<div class="text-xs text-gray-500">
{{ template.description }}
</div>
</div>
</div>
</el-card>
</div>
<div class="editor-section">
<!-- Query Editor -->
<el-card class="mb-4">
<template #header>
<div class="flex justify-between items-center">
<span class="font-bold">Query Editor</span>
<div class="flex gap-2">
<el-button
size="small"
@click="queryText = ''"
:icon="'Delete'"
>
Clear
</el-button>
<el-button
type="primary"
size="small"
@click="executeQuery"
:loading="executing"
:icon="'Play'"
>
Execute Query
</el-button>
</div>
</div>
</template>
<el-input
v-model="queryText"
type="textarea"
:rows="10"
placeholder="Enter your SELECT query here...&#10;&#10;Example:&#10;SELECT * FROM user WHERE is_org = 0 LIMIT 10;"
class="sql-editor"
/>
<div class="mt-2 space-y-1">
<div class="text-xs text-gray-600 dark:text-gray-400">
<div class="i-carbon-information inline-block mr-1" />
Only SELECT queries allowed. Max 1000 rows. Use LIMIT clause for better performance.
</div>
<div class="text-xs text-orange-600 dark:text-orange-400">
<div class="i-carbon-warning inline-block mr-1" />
<strong>Important:</strong> Use double quotes for table names with reserved keywords:
<code class="ml-1 px-1 bg-gray-100 dark:bg-gray-800 rounded">SELECT * FROM "user"</code>
</div>
</div>
</el-card>
<!-- Query Results -->
<el-card v-if="queryResults">
<template #header>
<div class="flex justify-between items-center">
<div>
<span class="font-bold">Results</span>
<el-tag class="ml-2" type="info">
{{ queryResults.columns.length }} columns
</el-tag>
<el-tag class="ml-2" type="success">
{{ queryResults.count }} rows
</el-tag>
<el-tag
v-if="queryResults.truncated"
class="ml-2"
type="warning"
>
Truncated to 1000
</el-tag>
</div>
<div class="flex gap-2">
<el-button size="small" @click="exportCSV" :icon="'Download'">
Export CSV
</el-button>
<el-button size="small" @click="exportJSON" :icon="'Download'">
Export JSON
</el-button>
</div>
</div>
</template>
<div v-if="queryResults.count === 0" class="text-center py-8">
<el-empty description="Query returned no results" />
</div>
<div v-else>
<!-- Column List (for debugging/info) -->
<div class="column-info">
<div>
<strong>Columns:</strong>
<span>{{ queryResults.columns.join(", ") }}</span>
</div>
</div>
<div class="results-table-wrapper">
<div class="results-table-container">
<el-table
:key="queryResults.columns.join(',')"
:data="queryResults.rows"
stripe
border
max-height="500"
fit
>
<el-table-column
v-for="column in queryResults.columns"
:key="column"
:prop="column"
:label="column"
min-width="120"
show-overflow-tooltip
>
<template #default="{ row }">
<div
class="cell-content"
:class="{ 'cell-null': row[column] === null }"
>
{{ row[column] !== null ? row[column] : "NULL" }}
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="scroll-hint" v-if="queryResults.columns.length > 5">
<div class="i-carbon-arrow-right" />
<span class="text-xs">Scroll horizontally to see more columns ({{ queryResults.columns.length }} total)</span>
</div>
</div>
</div>
</el-card>
<el-empty
v-else
description="Execute a query to see results"
:image-size="120"
/>
</div>
</div>
</div>
</AdminLayout>
</template>
<style scoped>
.page-container {
padding: 24px;
}
.database-viewer-container {
display: grid;
grid-template-columns: 300px 1fr;
gap: 24px;
align-items: start;
max-width: 100%;
overflow-x: hidden;
}
.sidebar-section {
position: sticky;
top: 20px;
min-width: 300px;
max-width: 300px;
}
.editor-section {
min-height: 600px;
min-width: 0; /* Important: allows grid item to shrink below content size */
overflow-x: hidden;
}
.tables-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 400px;
overflow-y: auto;
padding-right: 4px;
}
.tables-list::-webkit-scrollbar {
width: 6px;
}
.tables-list::-webkit-scrollbar-track {
background: var(--bg-hover);
border-radius: 3px;
}
.tables-list::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 3px;
}
.table-item {
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
background-color: var(--bg-hover);
border: 2px solid transparent;
}
.table-item:hover {
background-color: var(--bg-active);
border-color: var(--border-light);
transform: translateX(2px);
}
.table-item.selected {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15) 0%, rgba(59, 130, 246, 0.05) 100%);
border-color: var(--color-info);
box-shadow: var(--shadow-sm);
}
.table-item .font-mono {
color: var(--text-primary);
font-weight: 600;
}
.table-item .text-xs {
color: var(--text-secondary);
}
.templates-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
padding-right: 4px;
}
.templates-list::-webkit-scrollbar {
width: 6px;
}
.templates-list::-webkit-scrollbar-track {
background: var(--bg-hover);
border-radius: 3px;
}
.templates-list::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 3px;
}
.template-item {
padding: 12px;
border: 2px solid var(--border-default);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
background-color: var(--bg-card);
}
.template-item:hover {
border-color: var(--color-success);
background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, transparent 100%);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.template-item .font-semibold {
color: var(--text-primary);
margin-bottom: 4px;
}
.template-item .text-xs {
color: var(--text-secondary);
}
.sql-editor :deep(textarea) {
font-family: "Consolas", "Monaco", "Courier New", monospace;
font-size: 14px;
line-height: 1.6;
background-color: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-default);
}
.sql-editor :deep(textarea:focus) {
background-color: var(--bg-card);
border-color: var(--color-info);
}
.results-table-wrapper {
width: 100%;
max-width: 100%;
overflow: hidden;
}
.results-table-container {
overflow-x: auto;
overflow-y: visible;
border-radius: 8px;
border: 1px solid var(--border-default);
max-width: 100%;
}
.results-table-container::-webkit-scrollbar {
height: 12px;
}
.results-table-container::-webkit-scrollbar-track {
background: var(--bg-hover);
border-radius: 6px;
}
.results-table-container::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 6px;
border: 2px solid var(--bg-hover);
}
.results-table-container::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
.results-table-container :deep(.el-table) {
background-color: var(--bg-card);
min-width: 100%;
}
.results-table-container :deep(.el-table th) {
background-color: var(--bg-hover);
color: var(--text-primary);
font-weight: 600;
}
.results-table-container :deep(.el-table td) {
color: var(--text-primary);
}
.column-info {
padding: 12px;
background-color: var(--bg-hover);
border-radius: 8px;
border: 1px solid var(--border-light);
}
.column-info strong {
color: var(--text-primary);
}
.column-info span {
font-family: "Consolas", "Monaco", "Courier New", monospace;
font-size: 12px;
color: var(--text-secondary);
word-break: break-word;
}
.scroll-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
margin-top: 12px;
background-color: var(--bg-hover);
border-radius: 8px;
color: var(--text-secondary);
font-weight: 500;
}
.cell-content {
font-family: "Consolas", "Monaco", "Courier New", monospace;
font-size: 13px;
white-space: nowrap;
color: var(--text-primary);
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
padding: 2px 0;
}
/* Style for NULL values */
.cell-null {
color: var(--text-tertiary) !important;
font-style: italic;
opacity: 0.7;
}
/* Ensure cards don't overflow */
.editor-section :deep(.el-card) {
max-width: 100%;
overflow: hidden;
}
.editor-section :deep(.el-card__body) {
max-width: 100%;
overflow-x: hidden;
}
@media (max-width: 1024px) {
.database-viewer-container {
grid-template-columns: 1fr;
}
.sidebar-section {
position: static;
max-width: 100%;
}
}
</style>

View File

@@ -0,0 +1,430 @@
<script setup>
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import AdminLayout from "@/components/AdminLayout.vue";
import { useAdminStore } from "@/stores/admin";
import { getQuotaOverview, formatBytes } from "@/utils/api";
import { ElMessage } from "element-plus";
const router = useRouter();
const adminStore = useAdminStore();
const overview = ref(null);
const loading = ref(false);
function checkAuth() {
if (!adminStore.token) {
router.push("/login");
return false;
}
return true;
}
async function loadOverview() {
if (!checkAuth()) return;
loading.value = true;
try {
overview.value = await getQuotaOverview(adminStore.token);
} catch (error) {
console.error("Failed to load quota overview:", error);
if (error.response?.status === 401 || error.response?.status === 403) {
ElMessage.error("Invalid admin token. Please login again.");
adminStore.logout();
router.push("/login");
} else {
ElMessage.error(
error.response?.data?.detail?.error ||
"Failed to load quota overview",
);
}
} finally {
loading.value = false;
}
}
function navigateToUser(username) {
router.push(`/users`);
ElMessage.info(`Navigate to user: ${username}`);
}
function navigateToRepo() {
router.push(`/repositories`);
}
onMounted(() => {
loadOverview();
});
</script>
<template>
<AdminLayout>
<div class="page-container">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">
Quota Overview
</h1>
<el-button @click="loadOverview" :icon="'Refresh'" :loading="loading">
Refresh
</el-button>
</div>
<div v-loading="loading">
<!-- System Storage Summary -->
<el-card class="mb-6">
<template #header>
<div class="flex items-center gap-2">
<div class="i-carbon-data-volume text-blue-600 text-xl" />
<span class="font-bold text-lg">System Storage</span>
</div>
</template>
<div v-if="overview" class="storage-summary">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div class="stat-box">
<div class="stat-label">Private Storage</div>
<div class="stat-value">
{{ formatBytes(overview.system_storage.private_used) }}
</div>
</div>
<div class="stat-box">
<div class="stat-label">Public Storage</div>
<div class="stat-value">
{{ formatBytes(overview.system_storage.public_used) }}
</div>
</div>
<div class="stat-box">
<div class="stat-label">LFS Storage</div>
<div class="stat-value">
{{ formatBytes(overview.system_storage.lfs_used) }}
</div>
</div>
</div>
<div class="total-storage">
<div class="total-storage-label">
Total Storage Used
</div>
<div class="total-storage-value">
{{ formatBytes(overview.system_storage.total_used) }}
</div>
</div>
</div>
</el-card>
<!-- Warnings Section -->
<div
v-if="
overview &&
(overview.users_over_quota.length > 0 ||
overview.repos_over_quota.length > 0)
"
class="mb-6"
>
<el-alert type="warning" :closable="false" show-icon class="mb-4">
<template #title>
<span class="font-bold">
{{
overview.users_over_quota.length +
overview.repos_over_quota.length
}}
Warning(s) Detected
</span>
</template>
Some users or repositories have exceeded their storage quotas.
</el-alert>
<!-- Users Over Quota -->
<el-card v-if="overview.users_over_quota.length > 0" class="mb-4">
<template #header>
<div class="flex items-center gap-2">
<div class="i-carbon-warning-alt text-red-600 text-xl" />
<span class="font-bold">
Users Over Quota ({{ overview.users_over_quota.length }})
</span>
</div>
</template>
<el-table :data="overview.users_over_quota" stripe>
<el-table-column prop="username" label="Username" width="180">
<template #default="{ row }">
<el-button
type="text"
@click="navigateToUser(row.username)"
>
{{ row.username }}
</el-button>
</template>
</el-table-column>
<el-table-column label="Private Usage" width="200">
<template #default="{ row }">
<div class="quota-cell">
<span
:class="{
'text-red-600 font-semibold':
row.private_percentage > 100,
'text-orange-600': row.private_percentage > 90,
}"
>
{{ row.private_percentage }}%
</span>
<div class="text-xs text-gray-500">
{{ formatBytes(row.private_used) }} /
{{ formatBytes(row.private_quota) }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="Public Usage" width="200">
<template #default="{ row }">
<div class="quota-cell">
<span
:class="{
'text-red-600 font-semibold':
row.public_percentage > 100,
'text-orange-600': row.public_percentage > 90,
}"
>
{{ row.public_percentage }}%
</span>
<div class="text-xs text-gray-500">
{{ formatBytes(row.public_used) }} /
{{ formatBytes(row.public_quota) }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="Action" width="150" align="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="navigateToUser(row.username)"
>
Manage
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- Repositories Over Quota -->
<el-card v-if="overview.repos_over_quota.length > 0" class="mb-4">
<template #header>
<div class="flex items-center gap-2">
<div class="i-carbon-warning-alt text-orange-600 text-xl" />
<span class="font-bold">
Repositories Over Quota ({{ overview.repos_over_quota.length }})
</span>
</div>
</template>
<el-table :data="overview.repos_over_quota" stripe>
<el-table-column label="Repository" min-width="250">
<template #default="{ row }">
<div class="flex items-center gap-2">
<el-tag
:type="row.repo_type === 'model' ? 'primary' : row.repo_type === 'dataset' ? 'success' : 'warning'"
size="small"
>
{{ row.repo_type }}
</el-tag>
<span class="font-mono text-sm">{{ row.full_id }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="Usage" width="200">
<template #default="{ row }">
<div class="quota-cell">
<span class="text-red-600 font-semibold">
{{ row.percentage }}%
</span>
<div class="text-xs text-gray-500">
{{ formatBytes(row.used_bytes) }} /
{{ formatBytes(row.quota_bytes) }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="Action" width="150" align="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="navigateToRepo()"
>
View
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- Top Storage Consumers -->
<el-card class="mb-6">
<template #header>
<div class="flex items-center gap-2">
<div class="i-carbon-chart-bar text-purple-600 text-xl" />
<span class="font-bold">Top Storage Consumers</span>
</div>
</template>
<el-table
v-if="overview"
:data="overview.top_consumers"
stripe
max-height="400"
>
<el-table-column label="Rank" width="80" align="center">
<template #default="{ $index }">
<el-tag
:type="$index === 0 ? 'warning' : $index === 1 ? 'info' : ''"
size="small"
>
#{{ $index + 1 }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Name" min-width="200">
<template #default="{ row }">
<div class="flex items-center gap-2">
<div
:class="
row.is_org
? 'i-carbon-enterprise text-purple-600'
: 'i-carbon-user text-blue-600'
"
/>
<span class="font-semibold">{{ row.username }}</span>
<el-tag v-if="row.is_org" type="info" size="small" effect="plain">
Organization
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="Total Storage" width="180" align="right">
<template #default="{ row }">
<span class="font-mono font-semibold">
{{ formatBytes(row.total_bytes) }}
</span>
</template>
</el-table-column>
<el-table-column label="Action" width="120" align="right">
<template #default="{ row }">
<el-button
size="small"
@click="navigateToUser(row.username)"
>
View
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- No Warnings Message -->
<el-result
v-if="
overview &&
overview.users_over_quota.length === 0 &&
overview.repos_over_quota.length === 0
"
icon="success"
title="All Clear!"
sub-title="No users or repositories are over their storage quotas."
class="mb-6"
/>
</div>
</div>
</AdminLayout>
</template>
<style scoped>
.page-container {
padding: 24px;
}
.storage-summary {
padding: 12px 0;
}
.stat-box {
padding: 20px;
background: linear-gradient(135deg, var(--bg-hover) 0%, var(--bg-card) 100%);
border-radius: 12px;
text-align: center;
border: 1px solid var(--border-default);
transition: all 0.3s ease;
}
.stat-box:hover {
border-color: var(--color-info);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: var(--text-primary);
font-family: "SF Mono", "Monaco", "Consolas", monospace;
}
.total-storage {
padding: 28px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
text-align: center;
box-shadow: var(--shadow-lg);
margin-top: 16px;
}
.total-storage-label {
color: rgba(255, 255, 255, 0.95);
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
.total-storage-value {
color: #ffffff !important;
font-size: 36px;
font-weight: 800;
font-family: "SF Mono", "Monaco", "Consolas", monospace;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.quota-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.quota-cell .text-red-600 {
font-weight: 700;
}
.quota-cell .text-orange-600 {
font-weight: 600;
}
</style>

View File

@@ -277,4 +277,28 @@ onMounted(() => {
.page-container {
padding: 24px;
}
:deep(.el-card) {
background-color: var(--bg-card);
border-color: var(--border-default);
border-radius: 12px;
}
:deep(.el-table) {
background-color: var(--bg-card);
}
:deep(.el-table th) {
background-color: var(--bg-hover);
color: var(--text-primary);
font-weight: 600;
}
:deep(.el-table td) {
color: var(--text-primary);
}
:deep(.el-table__body tr:hover > td) {
background-color: var(--bg-hover) !important;
}
</style>

View File

@@ -3,8 +3,14 @@ import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import AdminLayout from "@/components/AdminLayout.vue";
import StatsCard from "@/components/StatsCard.vue";
import ChartCard from "@/components/ChartCard.vue";
import { useAdminStore } from "@/stores/admin";
import { getDetailedStats, getTopRepositories } from "@/utils/api";
import {
getDetailedStats,
getTopRepositories,
getTimeseriesStats,
getQuotaOverview,
} from "@/utils/api";
import { formatBytes } from "@/utils/api";
import { ElMessage } from "element-plus";
@@ -13,6 +19,9 @@ const adminStore = useAdminStore();
const stats = ref(null);
const topRepos = ref([]);
const loading = ref(false);
const timeseriesData = ref(null);
const chartDays = ref(30);
const quotaOverview = ref(null);
const hasData = computed(() => stats.value !== null);
@@ -24,13 +33,18 @@ async function loadStats() {
loading.value = true;
try {
stats.value = await getDetailedStats(adminStore.token);
const topReposData = await getTopRepositories(
adminStore.token,
5,
"commits",
);
const [statsData, topReposData, timeseriesResult, quotaData] =
await Promise.all([
getDetailedStats(adminStore.token),
getTopRepositories(adminStore.token, 5, "commits"),
getTimeseriesStats(adminStore.token, chartDays.value),
getQuotaOverview(adminStore.token),
]);
stats.value = statsData;
topRepos.value = topReposData.top_repositories;
timeseriesData.value = timeseriesResult;
quotaOverview.value = quotaData;
} catch (error) {
console.error("Failed to load stats:", error);
if (error.response?.status === 401 || error.response?.status === 403) {
@@ -47,6 +61,93 @@ async function loadStats() {
}
}
// Computed chart data for user growth
const userGrowthChart = computed(() => {
if (!timeseriesData.value) return null;
const dates = Object.keys(timeseriesData.value.users_by_day).sort();
const values = dates.map((date) => timeseriesData.value.users_by_day[date]);
return {
labels: dates,
datasets: [
{
label: "New Users",
data: values,
borderColor: "#409EFF",
backgroundColor: "rgba(64, 158, 255, 0.1)",
fill: true,
tension: 0.4,
},
],
};
});
// Computed chart data for repository growth
const repoGrowthChart = computed(() => {
if (!timeseriesData.value) return null;
const dates = Object.keys(timeseriesData.value.repositories_by_day).sort();
const modelData = dates.map(
(date) => timeseriesData.value.repositories_by_day[date]?.model || 0,
);
const datasetData = dates.map(
(date) => timeseriesData.value.repositories_by_day[date]?.dataset || 0,
);
const spaceData = dates.map(
(date) => timeseriesData.value.repositories_by_day[date]?.space || 0,
);
return {
labels: dates,
datasets: [
{
label: "Models",
data: modelData,
borderColor: "#409EFF",
backgroundColor: "rgba(64, 158, 255, 0.1)",
fill: true,
},
{
label: "Datasets",
data: datasetData,
borderColor: "#67C23A",
backgroundColor: "rgba(103, 194, 58, 0.1)",
fill: true,
},
{
label: "Spaces",
data: spaceData,
borderColor: "#E6A23C",
backgroundColor: "rgba(230, 162, 60, 0.1)",
fill: true,
},
],
};
});
// Computed chart data for commit activity
const commitActivityChart = computed(() => {
if (!timeseriesData.value) return null;
const dates = Object.keys(timeseriesData.value.commits_by_day).sort();
const values = dates.map((date) => timeseriesData.value.commits_by_day[date]);
return {
labels: dates,
datasets: [
{
label: "Commits",
data: values,
borderColor: "#F56C6C",
backgroundColor: "rgba(245, 108, 108, 0.1)",
fill: true,
tension: 0.4,
},
],
};
});
function getRepoTypeColor(type) {
switch (type) {
case "model":
@@ -120,6 +221,146 @@ onMounted(() => {
/>
</div>
<!-- Activity Charts -->
<div v-if="timeseriesData" class="charts-grid mb-6">
<ChartCard
v-if="userGrowthChart"
title="User Growth (Last 30 Days)"
:labels="userGrowthChart.labels"
:datasets="userGrowthChart.datasets"
:height="220"
/>
<ChartCard
v-if="repoGrowthChart"
title="Repository Creation (Last 30 Days)"
:labels="repoGrowthChart.labels"
:datasets="repoGrowthChart.datasets"
:height="220"
/>
<ChartCard
v-if="commitActivityChart"
title="Commit Activity (Last 30 Days)"
:labels="commitActivityChart.labels"
:datasets="commitActivityChart.datasets"
:height="220"
/>
</div>
<!-- Quota Warnings -->
<el-card
v-if="
quotaOverview &&
(quotaOverview.users_over_quota.length > 0 ||
quotaOverview.repos_over_quota.length > 0)
"
class="mb-6"
>
<template #header>
<div class="flex items-center gap-2">
<div class="i-carbon-warning text-orange-600" />
<span class="font-bold">Quota Warnings</span>
<el-tag type="warning" size="small">
{{
quotaOverview.users_over_quota.length +
quotaOverview.repos_over_quota.length
}}
</el-tag>
</div>
</template>
<div v-if="quotaOverview.users_over_quota.length > 0" class="mb-4">
<h3 class="text-sm font-semibold mb-2 text-red-600">
Users Over Quota ({{ quotaOverview.users_over_quota.length }})
</h3>
<el-table
:data="quotaOverview.users_over_quota.slice(0, 5)"
size="small"
stripe
>
<el-table-column prop="username" label="Username" width="150" />
<el-table-column label="Private" width="150" align="right">
<template #default="{ row }">
<span
:class="{
'text-red-600 font-semibold':
row.private_percentage > 100,
}"
>
{{ row.private_percentage }}%
</span>
</template>
</el-table-column>
<el-table-column label="Public" width="150" align="right">
<template #default="{ row }">
<span
:class="{
'text-red-600 font-semibold': row.public_percentage > 100,
}"
>
{{ row.public_percentage }}%
</span>
</template>
</el-table-column>
<el-table-column label="Action" width="150" align="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="$router.push(`/users`)"
>
Manage
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="quotaOverview.repos_over_quota.length > 0">
<h3 class="text-sm font-semibold mb-2 text-red-600">
Repositories Over Quota ({{ quotaOverview.repos_over_quota.length }})
</h3>
<el-table
:data="quotaOverview.repos_over_quota.slice(0, 5)"
size="small"
stripe
>
<el-table-column label="Repository" min-width="200">
<template #default="{ row }">
<el-tag :type="getRepoTypeColor(row.repo_type)" size="small">
{{ row.repo_type }}
</el-tag>
<span class="ml-2 font-mono text-sm">{{ row.full_id }}</span>
</template>
</el-table-column>
<el-table-column label="Usage" width="120" align="right">
<template #default="{ row }">
<span class="text-red-600 font-semibold">
{{ row.percentage }}%
</span>
</template>
</el-table-column>
<el-table-column label="Size" width="120" align="right">
<template #default="{ row }">
{{ formatBytes(row.used_bytes) }}
</template>
</el-table-column>
<el-table-column label="Action" width="150" align="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="$router.push(`/repositories`)"
>
View
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<!-- Top Contributors -->
<el-card class="mb-6" v-if="stats?.commits?.top_contributors?.length > 0">
<template #header>
@@ -216,4 +457,65 @@ onMounted(() => {
gap: 24px;
margin-bottom: 32px;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 24px;
}
/* Enhanced card styling */
:deep(.el-card) {
background-color: var(--bg-card);
border-color: var(--border-default);
transition: all 0.3s ease;
border-radius: 12px;
}
:deep(.el-card:hover) {
box-shadow: var(--shadow-md);
border-color: var(--border-strong);
}
:deep(.el-card__header) {
background-color: var(--bg-hover);
border-bottom-color: var(--border-default);
padding: 16px 20px;
}
:deep(.el-card__header .font-bold) {
color: var(--text-primary);
font-size: 15px;
}
/* Table styling */
:deep(.el-table) {
background-color: var(--bg-card);
}
:deep(.el-table th) {
background-color: var(--bg-hover);
color: var(--text-primary);
font-weight: 600;
border-color: var(--border-default);
}
:deep(.el-table td) {
color: var(--text-primary);
border-color: var(--border-light);
}
:deep(.el-table__body tr:hover > td) {
background-color: var(--bg-hover) !important;
}
/* Warning colors */
:deep(.text-red-600) {
color: var(--color-error) !important;
font-weight: 700;
}
:deep(.text-sm.text-gray-500) {
color: var(--text-secondary);
}
</style>

View File

@@ -478,7 +478,44 @@ onMounted(() => {
}
code {
font-family: monospace;
font-size: 0.9em;
font-family: "SF Mono", "Monaco", "Consolas", monospace;
font-size: 13px;
color: var(--text-primary);
background-color: var(--bg-hover);
padding: 2px 6px;
border-radius: 4px;
}
:deep(.el-card) {
background-color: var(--bg-card);
border-color: var(--border-default);
border-radius: 12px;
}
:deep(.el-table) {
background-color: var(--bg-card);
}
:deep(.el-table th) {
background-color: var(--bg-hover);
color: var(--text-primary);
font-weight: 600;
}
:deep(.el-table td) {
color: var(--text-primary);
}
:deep(.el-table__body tr:hover > td) {
background-color: var(--bg-hover) !important;
}
:deep(.text-gray-500) {
color: var(--text-secondary);
}
:deep(.text-green-600) {
color: var(--color-success);
font-weight: 600;
}
</style>

View File

@@ -1,90 +1,35 @@
<script setup>
import { ref, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import AdminLayout from "@/components/AdminLayout.vue";
import QuotaManager from "@/components/QuotaManager.vue";
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import { useAdminStore } from "@/stores/admin";
const route = useRoute();
const router = useRouter();
const adminStore = useAdminStore();
const namespace = ref("");
const isOrg = ref(false);
onMounted(() => {
if (!adminStore.token) {
router.push("/login");
return;
}
// Check if namespace is passed via query parameter
if (route.query.namespace) {
namespace.value = route.query.namespace;
isOrg.value = route.query.is_org === "true";
}
// Redirect to new Quota Overview page
router.push("/QuotaOverview");
});
watch(
() => route.query.namespace,
(newNamespace) => {
if (newNamespace) {
namespace.value = newNamespace;
isOrg.value = route.query.is_org === "true";
}
},
);
</script>
<template>
<AdminLayout>
<div class="page-container">
<h1 class="text-3xl font-bold mb-6 text-gray-900 dark:text-gray-100">
Quota Management
</h1>
<el-card class="mb-6">
<h2 class="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">
Select Namespace
</h2>
<div class="flex gap-4 items-end">
<el-form-item label="Namespace" class="flex-1">
<el-input
v-model="namespace"
placeholder="Enter username or organization name"
clearable
/>
</el-form-item>
<el-form-item label="Type">
<el-radio-group v-model="isOrg">
<el-radio :value="false">User</el-radio>
<el-radio :value="true">Organization</el-radio>
</el-radio-group>
</el-form-item>
</div>
</el-card>
<!-- Quota Manager Component -->
<QuotaManager
v-if="namespace && adminStore.token"
:namespace="namespace"
:is-org="isOrg"
:token="adminStore.token"
/>
<el-empty
v-else
description="Enter a namespace to manage quotas"
:image-size="200"
/>
</div>
</AdminLayout>
<div class="redirect-page">
<p>Redirecting to Quota Overview...</p>
</div>
</template>
<style scoped>
.el-form-item {
margin-bottom: 0;
.redirect-page {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 18px;
color: #606266;
}
</style>

View File

@@ -2,11 +2,14 @@
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import AdminLayout from "@/components/AdminLayout.vue";
import FileTree from "@/components/FileTree.vue";
import { useAdminStore } from "@/stores/admin";
import {
listRepositories,
getRepositoryDetails,
getRepositoryStorageBreakdown,
recalculateAllRepoStorage,
listCommits,
} from "@/utils/api";
import { formatBytes } from "@/utils/api";
import { ElMessage, ElMessageBox } from "element-plus";
@@ -19,6 +22,15 @@ const loading = ref(false);
const recalculating = ref(false);
const selectedRepo = ref(null);
const repoDialogVisible = ref(false);
const activeTab = ref("overview");
const storageBreakdown = ref(null);
const repoCommits = ref([]);
const loadingStorage = ref(false);
const loadingCommits = ref(false);
// Search
const searchQuery = ref("");
const searchDebounceTimer = ref(null);
// Filters
const filterRepoType = ref("");
@@ -86,6 +98,7 @@ async function loadRepositories() {
loading.value = true;
try {
const response = await listRepositories(adminStore.token, {
search: searchQuery.value || undefined,
repo_type: filterRepoType.value || undefined,
namespace: filterNamespace.value || undefined,
limit: pageSize.value,
@@ -108,6 +121,24 @@ async function loadRepositories() {
}
}
function handleSearchInput() {
// Clear existing timer
if (searchDebounceTimer.value) {
clearTimeout(searchDebounceTimer.value);
}
// Set new timer - wait 500ms after last input before searching
searchDebounceTimer.value = setTimeout(() => {
currentPage.value = 1; // Reset to first page when searching
loadRepositories();
}, 500);
}
function clearSearch() {
searchQuery.value = "";
loadRepositories();
}
async function handleViewRepo(row) {
if (!checkAuth()) return;
@@ -118,7 +149,11 @@ async function handleViewRepo(row) {
row.namespace,
row.name,
);
activeTab.value = "overview";
repoDialogVisible.value = true;
// Load storage breakdown in background
loadStorageBreakdown();
} catch (error) {
ElMessage.error(
error.response?.data?.detail?.error ||
@@ -127,6 +162,41 @@ async function handleViewRepo(row) {
}
}
async function loadStorageBreakdown() {
if (!selectedRepo.value) return;
loadingStorage.value = true;
try {
storageBreakdown.value = await getRepositoryStorageBreakdown(
adminStore.token,
selectedRepo.value.repo_type,
selectedRepo.value.namespace,
selectedRepo.value.name,
);
} catch (error) {
console.error("Failed to load storage breakdown:", error);
} finally {
loadingStorage.value = false;
}
}
async function loadRepoCommits() {
if (!selectedRepo.value) return;
loadingCommits.value = true;
try {
const response = await listCommits(adminStore.token, {
repo_full_id: selectedRepo.value.full_id,
limit: 20,
});
repoCommits.value = response.commits || [];
} catch (error) {
console.error("Failed to load commits:", error);
} finally {
loadingCommits.value = false;
}
}
function formatDate(dateStr) {
return dayjs(dateStr).format("YYYY-MM-DD HH:mm");
}
@@ -235,6 +305,27 @@ onMounted(() => {
</el-button>
</div>
<!-- Search Bar -->
<el-card class="mb-4">
<div class="flex gap-4 items-center">
<el-input
v-model="searchQuery"
placeholder="Search repositories by name or full ID..."
clearable
@input="handleSearchInput"
@clear="clearSearch"
style="max-width: 500px"
>
<template #prefix>
<div class="i-carbon-search text-gray-400" />
</template>
</el-input>
<span v-if="searchQuery" class="text-sm text-gray-500">
Searching for: "{{ searchQuery }}"
</span>
</div>
</el-card>
<!-- Filters -->
<el-card class="mb-4">
<div class="flex gap-4 items-end">
@@ -383,75 +474,208 @@ onMounted(() => {
</div>
</el-card>
<!-- Repository Details Dialog -->
<!-- Repository Details Dialog with Tabs -->
<el-dialog
v-model="repoDialogVisible"
title="Repository Details"
width="800px"
width="900px"
:title="selectedRepo ? selectedRepo.full_id : 'Repository Details'"
>
<div v-if="selectedRepo" class="repo-details">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{
selectedRepo.id
}}</el-descriptions-item>
<el-descriptions-item label="Type">
<el-tag :type="getRepoTypeColor(selectedRepo.repo_type)">
{{ selectedRepo.repo_type }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Full ID" :span="2">
<code class="font-mono">{{ selectedRepo.full_id }}</code>
</el-descriptions-item>
<el-descriptions-item label="Namespace">{{
selectedRepo.namespace
}}</el-descriptions-item>
<el-descriptions-item label="Name">{{
selectedRepo.name
}}</el-descriptions-item>
<el-descriptions-item label="Owner">{{
selectedRepo.owner_username
}}</el-descriptions-item>
<el-descriptions-item label="Visibility">
<el-tag :type="selectedRepo.private ? 'warning' : 'success'">
{{ selectedRepo.private ? "Private" : "Public" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Created">{{
formatDate(selectedRepo.created_at)
}}</el-descriptions-item>
<el-descriptions-item label="Files">
<strong>{{ selectedRepo.file_count }}</strong>
</el-descriptions-item>
<el-descriptions-item label="Commits">
<strong>{{ selectedRepo.commit_count }}</strong>
</el-descriptions-item>
<el-descriptions-item label="Total Size">
<strong>{{ formatBytes(selectedRepo.total_size) }}</strong>
</el-descriptions-item>
<el-descriptions-item label="Storage Used (Tracked)">
<strong>{{ formatBytes(selectedRepo.used_bytes) }}</strong>
</el-descriptions-item>
<el-descriptions-item label="Repository Quota">
<span v-if="selectedRepo.quota_bytes">
<strong>{{ formatBytes(selectedRepo.quota_bytes) }}</strong>
</span>
<span v-else class="text-gray-400">Inherit from namespace</span>
</el-descriptions-item>
</el-descriptions>
<el-tabs v-model="activeTab">
<!-- Overview Tab -->
<el-tab-pane label="Overview" name="overview">
<div class="mb-4">
<el-tag :type="getRepoTypeColor(selectedRepo.repo_type)" class="mr-2">
{{ selectedRepo.repo_type }}
</el-tag>
<el-tag
:type="selectedRepo.private ? 'warning' : 'success'"
effect="plain"
>
{{ selectedRepo.private ? "Private" : "Public" }}
</el-tag>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{
selectedRepo.id
}}</el-descriptions-item>
<el-descriptions-item label="Owner">{{
selectedRepo.owner_username
}}</el-descriptions-item>
<el-descriptions-item label="Namespace">{{
selectedRepo.namespace
}}</el-descriptions-item>
<el-descriptions-item label="Name">{{
selectedRepo.name
}}</el-descriptions-item>
<el-descriptions-item label="Created">{{
formatDate(selectedRepo.created_at)
}}</el-descriptions-item>
<el-descriptions-item label="Files">
<strong>{{ selectedRepo.file_count }}</strong>
</el-descriptions-item>
<el-descriptions-item label="Commits">
<strong>{{ selectedRepo.commit_count }}</strong>
</el-descriptions-item>
<el-descriptions-item label="Total Size">
<strong>{{ formatBytes(selectedRepo.total_size) }}</strong>
</el-descriptions-item>
<el-descriptions-item label="Storage Used">
<strong>{{ formatBytes(selectedRepo.used_bytes) }}</strong>
</el-descriptions-item>
<el-descriptions-item label="Repository Quota">
<span v-if="selectedRepo.quota_bytes">
<strong>{{ formatBytes(selectedRepo.quota_bytes) }}</strong>
</span>
<span v-else class="text-gray-400">Inherit from namespace</span>
</el-descriptions-item>
</el-descriptions>
<div class="mt-4">
<el-button
type="primary"
@click="
$router.push(
`/${selectedRepo.repo_type}s/${selectedRepo.namespace}/${selectedRepo.name}`,
)
"
>
<div class="i-carbon-launch mr-2" />
View in Main App
</el-button>
</div>
</el-tab-pane>
<!-- Files Tab -->
<el-tab-pane label="Files" name="files">
<FileTree
:repo-type="selectedRepo.repo_type"
:namespace="selectedRepo.namespace"
:name="selectedRepo.name"
:token="adminStore.token"
/>
</el-tab-pane>
<!-- Commits Tab -->
<el-tab-pane label="Commits" name="commits">
<div v-if="activeTab === 'commits' && !loadingCommits && repoCommits.length === 0">
<el-button type="primary" size="small" @click="loadRepoCommits" class="mb-4">
Load Commits
</el-button>
</div>
<el-table
v-if="repoCommits.length > 0"
:data="repoCommits"
v-loading="loadingCommits"
stripe
max-height="400"
>
<el-table-column label="SHA" width="100">
<template #default="{ row }">
<code class="text-xs">{{
row.commit_id.substring(0, 8)
}}</code>
</template>
</el-table-column>
<el-table-column prop="username" label="Author" width="120" />
<el-table-column label="Message" min-width="250">
<template #default="{ row }">
<div class="text-sm">{{ row.message }}</div>
</template>
</el-table-column>
<el-table-column prop="branch" label="Branch" width="100" />
<el-table-column label="Date" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
</el-table>
<div v-else-if="activeTab === 'commits'" class="text-center py-8">
<el-button type="primary" @click="loadRepoCommits" :loading="loadingCommits">
Load Commits
</el-button>
</div>
</el-tab-pane>
<!-- Storage Tab -->
<el-tab-pane label="Storage" name="storage">
<div v-if="loadingStorage" class="text-center py-8">
<el-skeleton :rows="5" animated />
</div>
<div v-else-if="storageBreakdown" class="storage-breakdown">
<h3 class="text-lg font-semibold mb-3">Storage Breakdown</h3>
<el-descriptions :column="2" border class="mb-4">
<el-descriptions-item label="Regular Files">
<strong>{{ formatBytes(storageBreakdown.regular_files_size) }}</strong>
<span class="text-xs text-gray-500 ml-2">
({{ ((storageBreakdown.regular_files_size / storageBreakdown.total_size) * 100).toFixed(1) }}%)
</span>
</el-descriptions-item>
<el-descriptions-item label="LFS Files">
<strong>{{ formatBytes(storageBreakdown.lfs_files_size) }}</strong>
<span class="text-xs text-gray-500 ml-2">
({{ ((storageBreakdown.lfs_files_size / storageBreakdown.total_size) * 100).toFixed(1) }}%)
</span>
</el-descriptions-item>
<el-descriptions-item label="Total Size">
<strong>{{ formatBytes(storageBreakdown.total_size) }}</strong>
</el-descriptions-item>
<el-descriptions-item label="LFS Objects">
<strong>{{ storageBreakdown.lfs_object_count }}</strong>
</el-descriptions-item>
<el-descriptions-item label="Unique LFS Objects">
<strong>{{ storageBreakdown.unique_lfs_objects }}</strong>
</el-descriptions-item>
<el-descriptions-item label="Deduplication Savings">
<strong>{{ formatBytes(storageBreakdown.deduplication_savings) }}</strong>
</el-descriptions-item>
</el-descriptions>
<div class="mt-4">
<h4 class="text-sm font-semibold mb-2">Storage Distribution</h4>
<div class="flex gap-2">
<div
class="storage-bar"
:style="{
width: ((storageBreakdown.regular_files_size / storageBreakdown.total_size) * 100) + '%',
backgroundColor: '#409EFF',
}"
>
<span v-if="storageBreakdown.regular_files_size > 0" class="text-xs text-white px-2">
Regular
</span>
</div>
<div
class="storage-bar"
:style="{
width: ((storageBreakdown.lfs_files_size / storageBreakdown.total_size) * 100) + '%',
backgroundColor: '#E6A23C',
}"
>
<span v-if="storageBreakdown.lfs_files_size > 0" class="text-xs text-white px-2">
LFS
</span>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-8">
<el-button type="primary" @click="loadStorageBreakdown">
Load Storage Analytics
</el-button>
</div>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button @click="repoDialogVisible = false">Close</el-button>
<el-button
type="primary"
@click="
$router.push(
`/${selectedRepo.repo_type}s/${selectedRepo.namespace}/${selectedRepo.name}`,
)
"
>
View in Main App
</el-button>
</template>
</el-dialog>
</div>
@@ -459,7 +683,87 @@ onMounted(() => {
</template>
<style scoped>
.page-container {
padding: 24px;
}
.repo-details {
padding: 12px 0;
}
.storage-breakdown {
padding: 12px 0;
}
.storage-bar {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-weight: 600;
font-size: 13px;
letter-spacing: 0.5px;
transition: all 0.3s ease;
box-shadow: var(--shadow-sm);
}
.storage-bar:hover {
opacity: 0.85;
transform: scale(1.02);
}
/* Card styling */
:deep(.el-card) {
background-color: var(--bg-card);
border-color: var(--border-default);
transition: all 0.2s ease;
}
:deep(.el-card:hover) {
box-shadow: var(--shadow-md);
}
/* Table styling */
:deep(.el-table) {
background-color: var(--bg-card);
}
:deep(.el-table th) {
background-color: var(--bg-hover);
color: var(--text-primary);
font-weight: 600;
}
:deep(.el-table td) {
color: var(--text-primary);
}
:deep(.el-table__body tr:hover > td) {
background-color: var(--bg-hover) !important;
}
/* Tabs styling */
:deep(.el-tabs__item) {
color: var(--text-secondary);
font-weight: 500;
}
:deep(.el-tabs__item.is-active) {
color: var(--color-info);
font-weight: 600;
}
:deep(.el-tabs__item:hover) {
color: var(--color-info);
}
:deep(.el-descriptions__label) {
color: var(--text-secondary);
font-weight: 600;
}
:deep(.el-descriptions__content) {
color: var(--text-primary);
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from "vue";
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import AdminLayout from "@/components/AdminLayout.vue";
import { useAdminStore } from "@/stores/admin";
@@ -15,7 +15,46 @@ const objects = ref([]);
const loadingBuckets = ref(false);
const loadingObjects = ref(false);
const selectedBucket = ref(null);
const objectPrefix = ref("");
const currentPath = ref("");
const pathParts = ref([]);
// Computed: Parse objects into folders and files
const folderStructure = computed(() => {
if (!objects.value || objects.value.length === 0) {
return { folders: [], files: [] };
}
const folders = new Set();
const files = [];
const prefix = currentPath.value;
objects.value.forEach((obj) => {
const key = obj.key;
// Skip if doesn't start with current path
if (prefix && !key.startsWith(prefix)) return;
// Get relative path from current prefix
const relativePath = prefix ? key.substring(prefix.length) : key;
// Check if this is a folder or file in current directory
const slashIndex = relativePath.indexOf("/");
if (slashIndex === -1) {
// It's a file in current directory
files.push({ ...obj, name: relativePath });
} else {
// It's inside a subfolder
const folderName = relativePath.substring(0, slashIndex);
folders.add(folderName);
}
});
return {
folders: Array.from(folders).sort(),
files: files.sort((a, b) => a.name.localeCompare(b.name)),
};
});
function checkAuth() {
if (!adminStore.token) {
@@ -53,7 +92,14 @@ async function loadObjects(bucket, prefix = "") {
loadingObjects.value = true;
selectedBucket.value = bucket;
objectPrefix.value = prefix;
currentPath.value = prefix;
// Update breadcrumb path parts
if (prefix) {
pathParts.value = prefix.split("/").filter((p) => p);
} else {
pathParts.value = [];
}
try {
const response = await listS3Objects(adminStore.token, bucket.name, {
@@ -71,6 +117,32 @@ async function loadObjects(bucket, prefix = "") {
}
}
function navigateToFolder(folderName) {
const newPath = currentPath.value + folderName + "/";
loadObjects(selectedBucket.value, newPath);
}
function navigateToBreadcrumb(index) {
if (index === -1) {
// Navigate to bucket root
loadObjects(selectedBucket.value, "");
} else {
// Navigate to specific path level
const newPath = pathParts.value.slice(0, index + 1).join("/") + "/";
loadObjects(selectedBucket.value, newPath);
}
}
function navigateUp() {
if (pathParts.value.length === 0) {
// Already at root, go back to buckets
clearObjectView();
} else {
// Go up one level
navigateToBreadcrumb(pathParts.value.length - 2);
}
}
function formatDate(dateStr) {
return dayjs(dateStr).format("YYYY-MM-DD HH:mm:ss");
}
@@ -91,19 +163,40 @@ function getBucketColor(bucket) {
}
function handleBrowseBucket(bucket) {
loadObjects(bucket);
}
function handleFilterByPrefix() {
if (selectedBucket.value) {
loadObjects(selectedBucket.value, objectPrefix.value);
}
loadObjects(bucket, "");
}
function clearObjectView() {
selectedBucket.value = null;
objects.value = [];
objectPrefix.value = "";
currentPath.value = "";
pathParts.value = [];
}
function getFileIcon(fileName) {
const ext = fileName.split(".").pop().toLowerCase();
const iconMap = {
// Archives
zip: "i-carbon-archive",
tar: "i-carbon-archive",
gz: "i-carbon-archive",
// Images
jpg: "i-carbon-image",
jpeg: "i-carbon-image",
png: "i-carbon-image",
gif: "i-carbon-image",
// Documents
pdf: "i-carbon-document-pdf",
txt: "i-carbon-document",
md: "i-carbon-document",
// Code
py: "i-carbon-logo-python",
js: "i-carbon-code",
json: "i-carbon-code",
// Default
default: "i-carbon-document",
};
return iconMap[ext] || iconMap.default;
}
onMounted(() => {
@@ -191,7 +284,7 @@ onMounted(() => {
</div>
</el-card>
<!-- Object Browser -->
<!-- File Explorer -->
<el-card v-if="selectedBucket">
<template #header>
<div class="flex items-center justify-between">
@@ -199,70 +292,117 @@ onMounted(() => {
<el-button size="small" @click="clearObjectView" :icon="'Back'">
Back to Buckets
</el-button>
<span class="font-bold">Bucket: {{ selectedBucket.name }}</span>
<span class="font-bold">{{ selectedBucket.name }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-gray-500">
<span>{{ folderStructure.folders.length }} folders</span>
<span>{{ folderStructure.files.length }} files</span>
<span>{{ objects.length }} total objects</span>
</div>
</div>
</template>
<!-- Prefix Filter -->
<div class="mb-4 flex gap-2">
<el-input
v-model="objectPrefix"
placeholder="Filter by prefix (e.g., lfs/, models/)"
clearable
style="max-width: 400px"
@keyup.enter="handleFilterByPrefix"
<!-- Breadcrumb Navigation -->
<div class="breadcrumb-container">
<el-button
size="small"
@click="navigateUp"
:icon="'ArrowLeft'"
class="mr-2"
:disabled="!selectedBucket"
>
<template #prepend>
<div class="i-carbon-search" />
</template>
</el-input>
<el-button type="primary" @click="handleFilterByPrefix">
Filter
Up
</el-button>
<el-breadcrumb separator="/" class="flex-1">
<el-breadcrumb-item @click="navigateToBreadcrumb(-1)" class="breadcrumb-item">
<div class="i-carbon-home text-blue-600" />
<span class="ml-1">Root</span>
</el-breadcrumb-item>
<el-breadcrumb-item
v-for="(part, index) in pathParts"
:key="index"
@click="navigateToBreadcrumb(index)"
class="breadcrumb-item"
>
<div class="i-carbon-folder text-orange-600" />
<span class="ml-1">{{ part }}</span>
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<!-- Objects Table -->
<el-empty
v-if="!loadingObjects && objects.length === 0"
description="No objects found in this bucket"
/>
<el-table
v-else
:data="objects"
v-loading="loadingObjects"
stripe
max-height="600"
>
<el-table-column label="Key" min-width="400">
<template #default="{ row }">
<code
class="text-xs font-mono text-gray-700 dark:text-gray-300"
>{{ row.key }}</code
>
</template>
</el-table-column>
<el-table-column label="Size" width="120" align="right">
<template #default="{ row }">
{{ formatBytes(row.size) }}
</template>
</el-table-column>
<el-table-column label="Storage Class" width="150">
<template #default="{ row }">
<el-tag size="small" type="info">
{{ row.storage_class }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Last Modified" width="180">
<template #default="{ row }">
{{ formatDate(row.last_modified) }}
</template>
</el-table-column>
</el-table>
<!-- File Explorer View -->
<div v-loading="loadingObjects">
<el-empty
v-if="!loadingObjects && objects.length === 0"
description="This bucket is empty"
/>
<div class="mt-4 text-sm text-gray-500">
Showing {{ objects.length }} object(s)
<div v-else class="explorer-container">
<!-- Folders List -->
<div v-if="folderStructure.folders.length > 0" class="mb-4">
<div class="section-header">
<div class="i-carbon-folder text-orange-600" />
<span>Folders ({{ folderStructure.folders.length }})</span>
</div>
<div class="folder-grid">
<div
v-for="folder in folderStructure.folders"
:key="folder"
class="folder-item"
@click="navigateToFolder(folder)"
>
<div class="i-carbon-folder text-4xl text-orange-500" />
<span class="folder-name">{{ folder }}</span>
</div>
</div>
</div>
<!-- Files Table -->
<div v-if="folderStructure.files.length > 0">
<div class="section-header">
<div class="i-carbon-document text-blue-600" />
<span>Files ({{ folderStructure.files.length }})</span>
</div>
<el-table
:data="folderStructure.files"
stripe
max-height="500"
>
<el-table-column label="Name" min-width="300">
<template #default="{ row }">
<div class="flex items-center gap-2">
<div :class="getFileIcon(row.name)" class="text-lg text-gray-600 dark:text-gray-400" />
<code class="text-sm font-mono">{{ row.name }}</code>
</div>
</template>
</el-table-column>
<el-table-column label="Size" width="120" align="right">
<template #default="{ row }">
<span class="font-mono text-sm">{{ formatBytes(row.size) }}</span>
</template>
</el-table-column>
<el-table-column label="Storage Class" width="150">
<template #default="{ row }">
<el-tag size="small" type="info">
{{ row.storage_class }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="Last Modified" width="180">
<template #default="{ row }">
{{ formatDate(row.last_modified) }}
</template>
</el-table-column>
</el-table>
</div>
<!-- Empty folder message -->
<el-empty
v-if="folderStructure.folders.length === 0 && folderStructure.files.length === 0"
description="This folder is empty"
/>
</div>
</div>
</el-card>
</div>
@@ -275,14 +415,146 @@ onMounted(() => {
}
.bucket-card {
background-color: white;
}
html.dark .bucket-card {
background-color: #1a1a1a;
background-color: var(--bg-card);
transition: all 0.3s ease;
border: 2px solid var(--border-default);
}
.bucket-card:hover {
border-color: #409eff;
border-color: var(--color-info);
box-shadow: var(--shadow-md);
transform: translateY(-4px);
background: linear-gradient(135deg, var(--bg-hover) 0%, var(--bg-card) 100%);
}
.bucket-card h3 {
color: var(--text-primary);
}
.bucket-card .text-xs {
color: var(--text-secondary);
}
.bucket-card .text-sm {
color: var(--text-secondary);
}
.bucket-card .font-semibold {
color: var(--text-primary);
}
/* File Explorer Styles */
.breadcrumb-container {
display: flex;
align-items: center;
padding: 16px;
background-color: var(--bg-hover);
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid var(--border-default);
}
.breadcrumb-item {
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
}
.breadcrumb-item:hover {
color: var(--color-info);
transform: scale(1.05);
}
.breadcrumb-item span {
font-weight: 500;
color: var(--text-primary);
}
.explorer-container {
margin-top: 16px;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background-color: var(--bg-hover);
border-radius: 8px;
margin-bottom: 12px;
font-weight: 600;
color: var(--text-primary);
border-left: 4px solid var(--color-info);
}
.folder-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.folder-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 16px;
background-color: var(--bg-card);
border: 2px solid var(--border-default);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
gap: 12px;
}
.folder-item:hover {
border-color: var(--color-warning);
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1) 0%, var(--bg-card) 100%);
transform: translateY(-4px);
box-shadow: var(--shadow-md);
}
.folder-name {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
text-align: center;
word-break: break-word;
max-width: 100%;
}
/* Table styling */
:deep(.el-table) {
background-color: var(--bg-card);
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th) {
background-color: var(--bg-hover);
color: var(--text-primary);
font-weight: 600;
}
:deep(.el-table td) {
color: var(--text-primary);
}
:deep(.el-table__body tr:hover > td) {
background-color: var(--bg-hover) !important;
}
/* Breadcrumb styling */
:deep(.el-breadcrumb__inner) {
display: flex;
align-items: center;
font-weight: 500;
color: var(--text-primary);
}
:deep(.el-breadcrumb__inner:hover) {
color: var(--color-info);
}
</style>

View File

@@ -22,6 +22,10 @@ const dialogVisible = ref(false);
const userDialogVisible = ref(false);
const selectedUser = ref(null);
// Search
const searchQuery = ref("");
const searchDebounceTimer = ref(null);
// Pagination
const currentPage = ref(1);
const pageSize = ref(20);
@@ -98,6 +102,7 @@ async function loadUsers() {
loading.value = true;
try {
const response = await listUsers(adminStore.token, {
search: searchQuery.value || undefined,
limit: pageSize.value,
offset: (currentPage.value - 1) * pageSize.value,
});
@@ -118,6 +123,24 @@ async function loadUsers() {
}
}
function handleSearchInput() {
// Clear existing timer
if (searchDebounceTimer.value) {
clearTimeout(searchDebounceTimer.value);
}
// Set new timer - wait 500ms after last input before searching
searchDebounceTimer.value = setTimeout(() => {
currentPage.value = 1; // Reset to first page when searching
loadUsers();
}, 500);
}
function clearSearch() {
searchQuery.value = "";
loadUsers();
}
async function handleViewUser(row) {
if (!checkAuth()) return;
@@ -277,6 +300,27 @@ onMounted(() => {
</el-button>
</div>
<!-- Search Bar -->
<el-card class="mb-4">
<div class="flex gap-4 items-center">
<el-input
v-model="searchQuery"
placeholder="Search users by username or email..."
clearable
@input="handleSearchInput"
@clear="clearSearch"
style="max-width: 500px"
>
<template #prefix>
<div class="i-carbon-search text-gray-400" />
</template>
</el-input>
<span v-if="searchQuery" class="text-sm text-gray-500">
Searching for: "{{ searchQuery }}"
</span>
</div>
</el-card>
<!-- Users Table -->
<el-card>
<el-empty
@@ -550,6 +594,10 @@ onMounted(() => {
</template>
<style scoped>
.page-container {
padding: 24px;
}
.user-details {
padding: 12px 0;
}
@@ -558,15 +606,56 @@ onMounted(() => {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.4;
line-height: 1.5;
gap: 2px;
}
.storage-cell span:first-child {
font-weight: 600;
font-family: "SF Mono", "Monaco", "Consolas", monospace;
}
.text-danger {
color: #f56c6c;
font-weight: 600;
color: var(--color-error) !important;
font-weight: 700;
}
.text-gray-400 {
color: #909399;
color: var(--text-tertiary);
font-size: 12px;
}
/* Search bar styling */
:deep(.el-card) {
background-color: var(--bg-card);
border-color: var(--border-default);
}
:deep(.el-input__wrapper) {
background-color: var(--bg-hover);
border-color: var(--border-default);
transition: all 0.2s ease;
}
:deep(.el-input__wrapper:hover) {
border-color: var(--color-info);
}
:deep(.el-table) {
background-color: var(--bg-card);
}
:deep(.el-table th) {
background-color: var(--bg-hover);
color: var(--text-primary);
font-weight: 600;
}
:deep(.el-table td) {
color: var(--text-primary);
}
:deep(.el-table__body tr:hover > td) {
background-color: var(--bg-hover) !important;
}
</style>

View File

@@ -0,0 +1,111 @@
/**
* KohakuHub Admin Portal - Color System
* Consistent colors for light and dark modes
*/
:root {
/* === Background Colors (Light Mode) === */
--bg-base: #f5f5f5;
--bg-elevated: #ffffff;
--bg-card: #ffffff;
--bg-hover: #f8f9fa;
--bg-active: #e9ecef;
/* === Text Colors (Light Mode) === */
--text-primary: #1a1a1a;
--text-secondary: #6c757d;
--text-tertiary: #adb5bd;
--text-inverse: #ffffff;
/* === Border Colors (Light Mode) === */
--border-default: #dee2e6;
--border-light: #e9ecef;
--border-strong: #ced4da;
/* === Entity Type Colors === */
--color-user: #3b82f6;
--color-repo: #10b981;
--color-commit: #f59e0b;
--color-org: #8b5cf6;
--color-lfs: #ef4444;
/* === Status Colors === */
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
/* === Interactive States === */
--hover-opacity: 0.8;
--active-opacity: 0.9;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
html.dark {
/* === Background Colors (Dark Mode) === */
--bg-base: #0a0a0a;
--bg-elevated: #1a1a1a;
--bg-card: #1f1f1f;
--bg-hover: #2a2a2a;
--bg-active: #333333;
/* === Text Colors (Dark Mode) === */
--text-primary: #f5f5f5;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--text-inverse: #1a1a1a;
/* === Border Colors (Dark Mode) === */
--border-default: #374151;
--border-light: #2a2a2a;
--border-strong: #4b5563;
/* === Entity Type Colors (Slightly adjusted for dark mode) === */
--color-user: #60a5fa;
--color-repo: #34d399;
--color-commit: #fbbf24;
--color-org: #a78bfa;
--color-lfs: #f87171;
/* === Status Colors (Slightly adjusted for dark mode) === */
--color-success: #4ade80;
--color-warning: #fbbf24;
--color-error: #f87171;
--color-info: #60a5fa;
/* === Interactive States === */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
/* === Utility Classes === */
.bg-base {
background-color: var(--bg-base);
}
.bg-elevated {
background-color: var(--bg-elevated);
}
.bg-card {
background-color: var(--bg-card);
}
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-tertiary {
color: var(--text-tertiary);
}
.border-default {
border-color: var(--border-default);
}

View File

@@ -20,8 +20,10 @@ declare module 'vue-router/auto-routes' {
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/commits': RouteRecordInfo<'/commits', '/commits', Record<never, never>, Record<never, never>>,
'/DatabaseViewer': RouteRecordInfo<'/DatabaseViewer', '/DatabaseViewer', Record<never, never>, Record<never, never>>,
'/invitations': RouteRecordInfo<'/invitations', '/invitations', Record<never, never>, Record<never, never>>,
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
'/QuotaOverview': RouteRecordInfo<'/QuotaOverview', '/QuotaOverview', Record<never, never>, Record<never, never>>,
'/quotas': RouteRecordInfo<'/quotas', '/quotas', Record<never, never>, Record<never, never>>,
'/repositories': RouteRecordInfo<'/repositories', '/repositories', Record<never, never>, Record<never, never>>,
'/storage': RouteRecordInfo<'/storage', '/storage', Record<never, never>, Record<never, never>>,
@@ -47,6 +49,10 @@ declare module 'vue-router/auto-routes' {
routes: '/commits'
views: never
}
'src/pages/DatabaseViewer.vue': {
routes: '/DatabaseViewer'
views: never
}
'src/pages/invitations.vue': {
routes: '/invitations'
views: never
@@ -55,6 +61,10 @@ declare module 'vue-router/auto-routes' {
routes: '/login'
views: never
}
'src/pages/QuotaOverview.vue': {
routes: '/QuotaOverview'
views: never
}
'src/pages/quotas.vue': {
routes: '/quotas'
views: never

View File

@@ -25,13 +25,19 @@ function createAdminClient(token) {
* List all users
* @param {string} token - Admin token
* @param {Object} params - Query parameters
* @param {string} params.search - Search by username or email
* @param {number} params.limit - Max users to return
* @param {number} params.offset - Offset for pagination
* @returns {Promise<Object>} User list response
*/
export async function listUsers(token, { limit = 100, offset = 0 } = {}) {
export async function listUsers(
token,
{ search, limit = 100, offset = 0 } = {},
) {
const client = createAdminClient(token);
const response = await client.get("/users", { params: { limit, offset } });
const response = await client.get("/users", {
params: { search, limit, offset },
});
return response.data;
}
@@ -143,6 +149,17 @@ export async function recalculateQuota(token, namespace, isOrg = false) {
return response.data;
}
/**
* Get quota overview with warnings
* @param {string} token - Admin token
* @returns {Promise<Object>} Quota overview data
*/
export async function getQuotaOverview(token) {
const client = createAdminClient(token);
const response = await client.get("/quota/overview");
return response.data;
}
// ===== System Stats =====
/**
@@ -218,15 +235,20 @@ export async function verifyAdminToken(token) {
* List all repositories
* @param {string} token - Admin token
* @param {Object} params - Query parameters
* @param {string} params.search - Search by repository full_id or name
* @param {string} params.repo_type - Filter by type (model/dataset/space)
* @param {string} params.namespace - Filter by namespace
* @param {number} params.limit - Max repositories to return
* @param {number} params.offset - Offset for pagination
* @returns {Promise<Object>} Repository list
*/
export async function listRepositories(
token,
{ repo_type, namespace, limit = 100, offset = 0 } = {},
{ search, repo_type, namespace, limit = 100, offset = 0 } = {},
) {
const client = createAdminClient(token);
const response = await client.get("/repositories", {
params: { repo_type, namespace, limit, offset },
params: { search, repo_type, namespace, limit, offset },
});
return response.data;
}
@@ -247,6 +269,51 @@ export async function getRepositoryDetails(token, repo_type, namespace, name) {
return response.data;
}
/**
* Get repository files with LFS metadata
* @param {string} token - Admin token
* @param {string} repo_type - Repository type
* @param {string} namespace - Namespace
* @param {string} name - Repository name
* @param {string} ref - Branch or commit reference
* @returns {Promise<Object>} File list with LFS info
*/
export async function getRepositoryFiles(
token,
repo_type,
namespace,
name,
ref = "main",
) {
const client = createAdminClient(token);
const response = await client.get(
`/repositories/${repo_type}/${namespace}/${name}/files`,
{ params: { ref } },
);
return response.data;
}
/**
* Get repository storage breakdown
* @param {string} token - Admin token
* @param {string} repo_type - Repository type
* @param {string} namespace - Namespace
* @param {string} name - Repository name
* @returns {Promise<Object>} Storage analytics
*/
export async function getRepositoryStorageBreakdown(
token,
repo_type,
namespace,
name,
) {
const client = createAdminClient(token);
const response = await client.get(
`/repositories/${repo_type}/${namespace}/${name}/storage-breakdown`,
);
return response.data;
}
// ===== Commit History =====
/**
@@ -413,3 +480,62 @@ export async function deleteInvitation(token, invitationToken) {
const response = await client.delete(`/invitations/${invitationToken}`);
return response.data;
}
// ===== Global Search =====
/**
* Global search across users, repositories, and commits
* @param {string} token - Admin token
* @param {string} q - Search query
* @param {Array<string>} types - Types to search (users, repos, commits)
* @param {number} limit - Max results per type
* @returns {Promise<Object>} Grouped search results
*/
export async function globalSearch(
token,
q,
types = ["users", "repos", "commits"],
limit = 20,
) {
const client = createAdminClient(token);
const response = await client.get("/search", {
params: { q, types, limit },
});
return response.data;
}
// ===== Database Viewer =====
/**
* List database tables
* @param {string} token - Admin token
* @returns {Promise<Object>} Tables with schemas
*/
export async function listDatabaseTables(token) {
const client = createAdminClient(token);
const response = await client.get("/database/tables");
return response.data;
}
/**
* Get query templates
* @param {string} token - Admin token
* @returns {Promise<Object>} Pre-defined query templates
*/
export async function getDatabaseQueryTemplates(token) {
const client = createAdminClient(token);
const response = await client.get("/database/templates");
return response.data;
}
/**
* Execute SQL query (read-only)
* @param {string} token - Admin token
* @param {string} sql - SQL query string
* @returns {Promise<Object>} Query results
*/
export async function executeDatabaseQuery(token, sql) {
const client = createAdminClient(token);
const response = await client.post("/database/query", { sql });
return response.data;
}