mirror of
https://github.com/KohakuBlueleaf/KohakuHub.git
synced 2026-04-28 09:57:43 -05:00
Redo the Admin portal for better UI/UX and more functionality
This commit is contained in:
43
src/kohaku-hub-admin/package-lock.json
generated
43
src/kohaku-hub-admin/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
10
src/kohaku-hub-admin/src/components.d.ts
vendored
10
src/kohaku-hub-admin/src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
111
src/kohaku-hub-admin/src/components/ChartCard.vue
Normal file
111
src/kohaku-hub-admin/src/components/ChartCard.vue
Normal 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>
|
||||
195
src/kohaku-hub-admin/src/components/FileTree.vue
Normal file
195
src/kohaku-hub-admin/src/components/FileTree.vue
Normal 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>
|
||||
588
src/kohaku-hub-admin/src/components/GlobalSearch.vue
Normal file
588
src/kohaku-hub-admin/src/components/GlobalSearch.vue
Normal 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>
|
||||
@@ -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";
|
||||
|
||||
|
||||
653
src/kohaku-hub-admin/src/pages/DatabaseViewer.vue
Normal file
653
src/kohaku-hub-admin/src/pages/DatabaseViewer.vue
Normal 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... Example: 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>
|
||||
430
src/kohaku-hub-admin/src/pages/QuotaOverview.vue
Normal file
430
src/kohaku-hub-admin/src/pages/QuotaOverview.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
111
src/kohaku-hub-admin/src/styles/colors.css
Normal file
111
src/kohaku-hub-admin/src/styles/colors.css
Normal 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);
|
||||
}
|
||||
10
src/kohaku-hub-admin/src/typed-router.d.ts
vendored
10
src/kohaku-hub-admin/src/typed-router.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user