mirror of
https://github.com/KohakuBlueleaf/KohakuHub.git
synced 2026-03-11 17:34:08 -05:00
add datasetviewer frontend module
This commit is contained in:
16
src/kohaku-hub-ui/package-lock.json
generated
16
src/kohaku-hub-ui/package-lock.json
generated
@@ -732,6 +732,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -776,6 +777,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1711,6 +1713,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": "*"
|
||||
}
|
||||
@@ -2243,6 +2246,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",
|
||||
@@ -2675,6 +2679,7 @@
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
|
||||
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "11.0.3",
|
||||
"@chevrotain/gast": "11.0.3",
|
||||
@@ -2858,6 +2863,7 @@
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
||||
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
@@ -3258,6 +3264,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -4400,13 +4407,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",
|
||||
@@ -4827,6 +4836,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -5530,6 +5540,7 @@
|
||||
"integrity": "sha512-eSiiRJmovt8qDJkGyZuLnbxAOAdie6NCmmd0NkTC0RJI9duiSBTfr8X2mBYJOUFzxQa2USaHmL99J9uMxkjCyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/runtime": "0.92.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -5654,6 +5665,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",
|
||||
|
||||
5
src/kohaku-hub-ui/src/components.d.ts
vendored
5
src/kohaku-hub-ui/src/components.d.ts
vendored
@@ -11,7 +11,11 @@ declare module 'vue' {
|
||||
AvatarUpload: typeof import('./components/profile/AvatarUpload.vue')['default']
|
||||
CodeEditor: typeof import('./components/common/CodeEditor.vue')['default']
|
||||
CodeViewer: typeof import('./components/common/CodeViewer.vue')['default']
|
||||
DataGrid: typeof import('./components/DatasetViewer/DataGrid.vue')['default']
|
||||
DataGridEnhanced: typeof import('./components/DatasetViewer/DataGridEnhanced.vue')['default']
|
||||
DatasetInfoCard: typeof import('./components/repo/metadata/DatasetInfoCard.vue')['default']
|
||||
DatasetViewer: typeof import('./components/DatasetViewer/DatasetViewer.vue')['default']
|
||||
DatasetViewerTab: typeof import('./components/repo/DatasetViewerTab.vue')['default']
|
||||
DetailedMetadataPanel: typeof import('./components/repo/metadata/DetailedMetadataPanel.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
@@ -59,6 +63,7 @@ declare module 'vue' {
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SidebarRelationshipsCard: typeof import('./components/repo/metadata/SidebarRelationshipsCard.vue')['default']
|
||||
SocialLinks: typeof import('./components/profile/SocialLinks.vue')['default']
|
||||
TARFileList: typeof import('./components/DatasetViewer/TARFileList.vue')['default']
|
||||
TheFooter: typeof import('./components/layout/TheFooter.vue')['default']
|
||||
TheHeader: typeof import('./components/layout/TheHeader.vue')['default']
|
||||
}
|
||||
|
||||
152
src/kohaku-hub-ui/src/components/DatasetViewer/DataGrid.vue
Normal file
152
src/kohaku-hub-ui/src/components/DatasetViewer/DataGrid.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
rows: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
truncated: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Sorting
|
||||
const sortColumn = ref(null);
|
||||
const sortDirection = ref("asc");
|
||||
|
||||
// Sorting logic
|
||||
const sortedRows = computed(() => {
|
||||
if (!sortColumn.value) return props.rows;
|
||||
|
||||
const colIndex = props.columns.indexOf(sortColumn.value);
|
||||
if (colIndex === -1) return props.rows;
|
||||
|
||||
const sorted = [...props.rows].sort((a, b) => {
|
||||
const aVal = a[colIndex];
|
||||
const bVal = b[colIndex];
|
||||
|
||||
// Handle null/undefined
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
// Numeric comparison
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
return aVal - bVal;
|
||||
}
|
||||
|
||||
// String comparison
|
||||
return String(aVal).localeCompare(String(bVal));
|
||||
});
|
||||
|
||||
return sortDirection.value === "desc" ? sorted.reverse() : sorted;
|
||||
});
|
||||
|
||||
// Toggle sort
|
||||
function toggleSort(column) {
|
||||
if (sortColumn.value === column) {
|
||||
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortColumn.value = column;
|
||||
sortDirection.value = "asc";
|
||||
}
|
||||
}
|
||||
|
||||
// Format cell value
|
||||
function formatValue(value) {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-grid-container">
|
||||
<!-- Truncation warning -->
|
||||
<div
|
||||
v-if="truncated"
|
||||
class="warning p-3 bg-yellow-50 dark:bg-yellow-900/20 border-b border-yellow-200 dark:border-yellow-700"
|
||||
>
|
||||
<span class="text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ Showing first {{ rows.length }} rows. File contains more data.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Data table -->
|
||||
<div class="table-wrapper overflow-auto">
|
||||
<table class="data-table w-full border-collapse">
|
||||
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="column-header px-4 py-3 text-left text-sm font-semibold border-b-2 border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@click="toggleSort(column)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ column }}</span>
|
||||
<span v-if="sortColumn === column" class="sort-indicator">
|
||||
{{ sortDirection === "asc" ? "↑" : "↓" }}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, rowIndex) in sortedRows"
|
||||
:key="rowIndex"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<td
|
||||
v-for="(value, colIndex) in row"
|
||||
:key="colIndex"
|
||||
class="cell px-4 py-2 text-sm border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{{ formatValue(value) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="rows.length === 0"
|
||||
class="empty-state p-8 text-center text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
No rows to display
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-wrapper {
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
color: #3b82f6;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
rows: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
truncated: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Sorting
|
||||
const sortColumn = ref(null);
|
||||
const sortDirection = ref("asc");
|
||||
|
||||
// Row detail
|
||||
const selectedRowIndex = ref(null);
|
||||
|
||||
// Max cell length before truncation
|
||||
const MAX_CELL_LENGTH = 100;
|
||||
|
||||
// Sorting logic
|
||||
const sortedRows = computed(() => {
|
||||
if (!sortColumn.value) return props.rows;
|
||||
|
||||
const colIndex = props.columns.indexOf(sortColumn.value);
|
||||
if (colIndex === -1) return props.rows;
|
||||
|
||||
const sorted = [...props.rows].sort((a, b) => {
|
||||
const aVal = a[colIndex];
|
||||
const bVal = b[colIndex];
|
||||
|
||||
// Handle null/undefined
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
// Numeric comparison
|
||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
||||
return aVal - bVal;
|
||||
}
|
||||
|
||||
// String comparison
|
||||
return String(aVal).localeCompare(String(bVal));
|
||||
});
|
||||
|
||||
return sortDirection.value === "desc" ? sorted.reverse() : sorted;
|
||||
});
|
||||
|
||||
// Toggle sort
|
||||
function toggleSort(column) {
|
||||
if (sortColumn.value === column) {
|
||||
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
sortColumn.value = column;
|
||||
sortDirection.value = "asc";
|
||||
}
|
||||
}
|
||||
|
||||
// Format cell value
|
||||
function formatValue(value) {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// Check if cell is long
|
||||
function isCellLong(value) {
|
||||
const formatted = formatValue(value);
|
||||
return formatted.length > MAX_CELL_LENGTH;
|
||||
}
|
||||
|
||||
// Truncate cell value
|
||||
function truncateValue(value) {
|
||||
const formatted = formatValue(value);
|
||||
if (formatted.length <= MAX_CELL_LENGTH) return formatted;
|
||||
return formatted.substring(0, MAX_CELL_LENGTH) + "...";
|
||||
}
|
||||
|
||||
// Check if value is an image (base64 or binary indicator)
|
||||
function isImage(value) {
|
||||
if (!value) return false;
|
||||
const str = String(value);
|
||||
// Check for base64 image data URL
|
||||
if (str.startsWith("data:image/")) return true;
|
||||
// Check for <binary> indicator from backend
|
||||
if (str.startsWith("<binary:") || str.startsWith("<large file:"))
|
||||
return false;
|
||||
// Check for very long strings that might be base64 (but not detect as image without data URL)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get image data URL
|
||||
function getImageDataUrl(value) {
|
||||
const str = String(value);
|
||||
if (str.startsWith("data:image/")) return str;
|
||||
// If it's base64 without prefix, add it
|
||||
if (str.length > 100 && !str.includes(" ")) {
|
||||
return `data:image/png;base64,${str}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Toggle row detail
|
||||
function toggleRowDetail(index) {
|
||||
if (selectedRowIndex.value === index) {
|
||||
selectedRowIndex.value = null;
|
||||
} else {
|
||||
selectedRowIndex.value = index;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="data-grid-enhanced">
|
||||
<!-- Truncation warning -->
|
||||
<div
|
||||
v-if="truncated"
|
||||
class="warning p-3 bg-yellow-50 dark:bg-yellow-900/20 border-b border-yellow-200 dark:border-yellow-700"
|
||||
>
|
||||
<span class="text-yellow-800 dark:text-yellow-200">
|
||||
Showing first {{ rows.length }} rows. File contains more data.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Data table -->
|
||||
<div class="table-wrapper overflow-auto">
|
||||
<table class="data-table w-full border-collapse">
|
||||
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th
|
||||
class="column-header px-2 py-3 text-left text-sm font-semibold border-b-2 border-gray-200 dark:border-gray-700"
|
||||
style="width: 40px"
|
||||
>
|
||||
#
|
||||
</th>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column"
|
||||
class="column-header px-4 py-3 text-left text-sm font-semibold border-b-2 border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@click="toggleSort(column)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ column }}</span>
|
||||
<span
|
||||
v-if="sortColumn === column"
|
||||
class="sort-indicator text-blue-500"
|
||||
>
|
||||
{{ sortDirection === "asc" ? "↑" : "↓" }}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(row, rowIndex) in sortedRows" :key="rowIndex">
|
||||
<!-- Main row -->
|
||||
<tr
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
|
||||
@click="toggleRowDetail(rowIndex)"
|
||||
:class="{
|
||||
'bg-blue-50 dark:bg-blue-900/20': selectedRowIndex === rowIndex,
|
||||
}"
|
||||
>
|
||||
<td
|
||||
class="cell px-2 py-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{{ rowIndex + 1 }}
|
||||
</td>
|
||||
<td
|
||||
v-for="(value, colIndex) in row"
|
||||
:key="colIndex"
|
||||
class="cell px-4 py-2 text-sm border-b border-gray-200 dark:border-gray-700"
|
||||
:class="{
|
||||
'text-blue-600 dark:text-blue-400': isCellLong(value),
|
||||
}"
|
||||
>
|
||||
{{ truncateValue(value) }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Detail row (expanded) -->
|
||||
<tr
|
||||
v-if="selectedRowIndex === rowIndex"
|
||||
class="detail-row bg-gray-100 dark:bg-gray-800"
|
||||
>
|
||||
<td
|
||||
:colspan="columns.length + 1"
|
||||
class="p-4 border-b border-gray-300 dark:border-gray-600"
|
||||
>
|
||||
<div class="detail-content">
|
||||
<div
|
||||
class="text-sm font-semibold mb-3 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Row {{ rowIndex + 1 }} Details
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="(value, colIndex) in row"
|
||||
:key="colIndex"
|
||||
class="detail-field p-3 bg-white dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div
|
||||
class="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-1"
|
||||
>
|
||||
{{ columns[colIndex] }}
|
||||
</div>
|
||||
|
||||
<!-- Image value -->
|
||||
<div v-if="isImage(value)" class="image-preview">
|
||||
<img
|
||||
:src="getImageDataUrl(value)"
|
||||
alt="Preview"
|
||||
class="max-w-full max-h-48 border border-gray-300 dark:border-gray-600 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Text value -->
|
||||
<div
|
||||
v-else
|
||||
class="text-sm text-gray-900 dark:text-gray-100 break-words font-mono whitespace-pre-wrap"
|
||||
>
|
||||
{{ formatValue(value) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="rows.length === 0"
|
||||
class="empty-state p-8 text-center text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
No rows to display
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-wrapper {
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.detail-field {
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.detail-field:hover {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
267
src/kohaku-hub-ui/src/components/DatasetViewer/DatasetViewer.vue
Normal file
267
src/kohaku-hub-ui/src/components/DatasetViewer/DatasetViewer.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import {
|
||||
previewFile,
|
||||
executeSQLQuery,
|
||||
listTARFiles,
|
||||
extractTARFile,
|
||||
detectFormat,
|
||||
formatBytes,
|
||||
} from "./api";
|
||||
import DataGridEnhanced from "./DataGridEnhanced.vue";
|
||||
import TARFileList from "./TARFileList.vue";
|
||||
|
||||
const props = defineProps({
|
||||
fileUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fileName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
maxRows: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
sqlQuery: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
useSQL: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["error", "warning"]);
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const previewData = ref(null);
|
||||
const fileFormat = ref(null);
|
||||
|
||||
// TAR-specific state
|
||||
const isTAR = ref(false);
|
||||
const tarFiles = ref([]);
|
||||
const selectedTARFile = ref(null);
|
||||
|
||||
// Load preview
|
||||
async function loadPreview() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
previewData.value = null;
|
||||
|
||||
try {
|
||||
// Detect format
|
||||
fileFormat.value = detectFormat(props.fileName);
|
||||
|
||||
if (!fileFormat.value) {
|
||||
throw new Error("Unsupported file format");
|
||||
}
|
||||
|
||||
// Handle TAR archives differently
|
||||
if (fileFormat.value === "tar") {
|
||||
isTAR.value = true;
|
||||
await loadTARFiles();
|
||||
} else {
|
||||
isTAR.value = false;
|
||||
await loadFilePreview(props.fileUrl, fileFormat.value);
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.detail || err.message;
|
||||
emit("error", error.value);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load TAR file listing
|
||||
async function loadTARFiles() {
|
||||
const result = await listTARFiles(props.fileUrl);
|
||||
tarFiles.value = result.files;
|
||||
}
|
||||
|
||||
// Handle TAR file selection
|
||||
async function selectTARFile(file) {
|
||||
selectedTARFile.value = file;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// Extract file from TAR
|
||||
const blob = await extractTARFile(props.fileUrl, file.name);
|
||||
|
||||
// Create temporary URL for extracted file
|
||||
const tempUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Detect format from file name
|
||||
const extractedFormat = detectFormat(file.name);
|
||||
|
||||
if (!extractedFormat || extractedFormat === "tar") {
|
||||
throw new Error("Cannot preview this file type");
|
||||
}
|
||||
|
||||
// Preview extracted file
|
||||
await loadFilePreview(tempUrl, extractedFormat);
|
||||
|
||||
// Cleanup temp URL
|
||||
URL.revokeObjectURL(tempUrl);
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.detail || err.message;
|
||||
selectedTARFile.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load file preview (non-TAR)
|
||||
async function loadFilePreview(url, format) {
|
||||
// Use SQL query if enabled
|
||||
if (props.useSQL && props.sqlQuery.trim()) {
|
||||
// Check for LIMIT clause
|
||||
const queryUpper = props.sqlQuery.toUpperCase();
|
||||
if (!queryUpper.includes("LIMIT")) {
|
||||
emit(
|
||||
"warning",
|
||||
"No LIMIT clause found - adding LIMIT 10000 automatically",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await executeSQLQuery(url, props.sqlQuery, {
|
||||
format,
|
||||
maxRows: 10000, // Backend will apply this if no LIMIT
|
||||
});
|
||||
previewData.value = result;
|
||||
} else {
|
||||
// Regular preview
|
||||
const result = await previewFile(url, {
|
||||
format,
|
||||
maxRows: props.maxRows,
|
||||
});
|
||||
previewData.value = result;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for URL changes
|
||||
watch(() => props.fileUrl, loadPreview, { immediate: true });
|
||||
|
||||
// Stats
|
||||
const stats = computed(() => {
|
||||
if (!previewData.value) return null;
|
||||
|
||||
return {
|
||||
columns: previewData.value.columns.length,
|
||||
rows: previewData.value.total_rows,
|
||||
truncated: previewData.value.truncated,
|
||||
fileSize: formatBytes(previewData.value.file_size),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="dataset-viewer bg-white dark:bg-gray-900 text-black dark:text-white"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="header p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">{{ fileName }}</h3>
|
||||
<div
|
||||
v-if="stats"
|
||||
class="text-sm text-gray-600 dark:text-gray-400 mt-1"
|
||||
>
|
||||
{{ stats.columns }} columns × {{ stats.rows }} rows
|
||||
<span
|
||||
v-if="stats.truncated"
|
||||
class="text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
(truncated to {{ maxRows }} rows)
|
||||
</span>
|
||||
<span v-if="stats.fileSize !== 'Unknown'" class="ml-2">
|
||||
· {{ stats.fileSize }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="fileFormat"
|
||||
class="badge px-3 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-sm"
|
||||
>
|
||||
{{ fileFormat.toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAR file indicator -->
|
||||
<div
|
||||
v-if="selectedTARFile"
|
||||
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Viewing: {{ selectedTARFile.name }} ({{
|
||||
formatBytes(selectedTARFile.size)
|
||||
}})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="loading p-8 text-center">
|
||||
<div
|
||||
class="spinner inline-block w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
<div class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Loading preview...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="error p-8 text-center">
|
||||
<div class="text-red-600 dark:text-red-400 text-lg">⚠️ Error</div>
|
||||
<div class="mt-2 text-gray-700 dark:text-gray-300">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<!-- TAR file listing -->
|
||||
<div v-else-if="isTAR && !selectedTARFile" class="tar-listing">
|
||||
<TARFileList :files="tarFiles" @select="selectTARFile" />
|
||||
</div>
|
||||
|
||||
<!-- Data grid -->
|
||||
<div v-else-if="previewData" class="data-grid">
|
||||
<DataGridEnhanced
|
||||
:columns="previewData.columns"
|
||||
:rows="previewData.rows"
|
||||
:truncated="previewData.truncated"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- No data -->
|
||||
<div
|
||||
v-else
|
||||
class="no-data p-8 text-center text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
No data to display
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dataset-viewer {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dark .dataset-viewer {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
205
src/kohaku-hub-ui/src/components/DatasetViewer/LICENSE
Normal file
205
src/kohaku-hub-ui/src/components/DatasetViewer/LICENSE
Normal file
@@ -0,0 +1,205 @@
|
||||
|
||||
# Kohaku Software License 1.0
|
||||
|
||||
**Published by KohakuBlueLeaf**
|
||||
|
||||
## Purpose
|
||||
|
||||
The **Kohaku Software License** aims to provide maximum freedom for users to work with the Software while protecting contributors from liability and ensuring the freedom of end users. It incorporates commercial usage restrictions to balance open access with sustainable development.
|
||||
|
||||
## Definitions
|
||||
|
||||
- **Software**: Refers to the source code, compiled binaries, libraries, modules, documentation, configuration files, and any other materials provided under this License.
|
||||
|
||||
- **Source Code**: The preferred form for making modifications to the Software, including all source files, build scripts, configuration files, and documentation necessary to understand, compile, and modify the Software.
|
||||
|
||||
- **Derivative Work**: Any software based on or derived from the original Software, including but not limited to:
|
||||
- Modified versions of the Software
|
||||
- Software that incorporates any portion of the Software
|
||||
- Software that links to, imports, or otherwise depends on the Software in a manner that creates a combined work
|
||||
|
||||
For a Derivative Work to qualify under this license, it must include the complete Source Code necessary to build, use, and modify the Derivative Work.
|
||||
|
||||
- **Modify**: To alter, adapt, translate, or otherwise change the Software, or to create Derivative Works.
|
||||
|
||||
- **Service Provider**: An entity that uses the Software to offer services to **End Users**, thereby making the **End Users** the recipients of the service.
|
||||
|
||||
- **End User**: Any individual or entity that uses the Software directly or uses services provided by a **Service Provider** that utilizes the Software.
|
||||
|
||||
- **Non-Commercial Purpose**: Uses that do not involve direct or indirect monetary compensation arising from the use of the Software, including personal use, academic research, experimentation, testing, or non-commercial organizational use.
|
||||
|
||||
- **Commercial Usage**: Any use of the Software where:
|
||||
- The Software is used to provide services or products to customers, clients, or users (internal or external) for monetary compensation, or
|
||||
- The Software is incorporated into commercial products or services, or
|
||||
- The Software is used as part of internal company systems that help internal teams execute their business operations in a for-profit organization, or
|
||||
- The organization using the Software generates revenue from activities directly or indirectly involving the Software
|
||||
|
||||
- **Total Revenue**:
|
||||
- For Service Providers: The total revenue generated from services utilizing the Software
|
||||
- For product vendors: The total revenue from products incorporating the Software
|
||||
- For internal business systems: The total revenue of the organization using the Software for business operations
|
||||
|
||||
## License Grant
|
||||
|
||||
### 1. General Permissions
|
||||
|
||||
Subject to compliance with this License, KohakuBlueLeaf grants you a non-exclusive, worldwide, non-transferable, non-sublicensable, revocable, royalty-free, and limited license to access, use, modify, create Derivative Works, and distribute the Software for **Non-Commercial Purposes** and **Commercial Usage** under certain conditions.
|
||||
|
||||
### 2. Categories of Use
|
||||
|
||||
#### a. Direct Users
|
||||
|
||||
Individuals or entities that use the Software directly for their personal, academic, or non-commercial purposes without operating in a commercial capacity.
|
||||
|
||||
#### b. Service Providers and Commercial Entities
|
||||
|
||||
Entities that use the Software to offer services or products to **End Users**, or that use the Software for internal business operations in a for-profit organization.
|
||||
|
||||
### 3. Source Code Availability
|
||||
|
||||
When using or distributing the Software or any Derivative Works, you must:
|
||||
|
||||
- Make the complete Source Code available to recipients
|
||||
- Ensure the Source Code is in a form that allows recipients to build, modify, and use the Software
|
||||
- Include all necessary build scripts, configuration files, and dependencies information
|
||||
|
||||
### 4. Derivative Works
|
||||
|
||||
Any Derivative Works created must be published under the **Kohaku Software License**. The minimal requirement includes:
|
||||
|
||||
- Complete Source Code of the Derivative Work
|
||||
- Build and installation instructions
|
||||
- Clear indication of what has been modified from the original Software
|
||||
|
||||
**Additional Requirements for Combined Works:**
|
||||
|
||||
- If the Derivative Work combines multiple software components or libraries, all such components that form a combined work must be published under this License or a compatible license.
|
||||
- You must provide clear documentation on how the components interact and how to build the combined work.
|
||||
- **Note**: You are not obligated to release proprietary business logic or workflows that use the Software through standard APIs or interfaces without creating Derivative Works.
|
||||
|
||||
## Restrictions
|
||||
|
||||
### 1. Commercial Usage
|
||||
|
||||
- **Definition**: **Commercial Usage** is defined as any use where:
|
||||
- The Software is used to provide services or products to customers, clients, or users (internal or external) for monetary compensation
|
||||
- The Software is incorporated into commercial products or services
|
||||
- The Software is used as part of internal company systems that help internal teams execute their business operations in a for-profit organization
|
||||
- The organization using the Software generates revenue from activities directly or indirectly involving the Software
|
||||
|
||||
- **Conditions for Requiring a Commercial License**: Commercial Usage is prohibited **if either** of the following conditions are met:
|
||||
- **Total Revenue** attributable to or associated with the Software exceeds $25,000 USD per year, OR
|
||||
- **Usage Duration** exceeds 3 months
|
||||
|
||||
- **Revenue Threshold and Usage Duration**:
|
||||
- **Trial Period**: Entities are allowed to engage in **Commercial Usage** without a commercial license for a trial period of **up to 3 months**, provided their **Total Revenue** remains below or equal to $25,000 USD per year.
|
||||
- **Revenue Limit**: Entities with **Total Revenue** attributable to or associated with the Software below or equal to $25,000 USD per year are permitted to continue **Commercial Usage** without a commercial license, provided the **Usage Duration** does not exceed 3 months.
|
||||
- **Exceeding Either Threshold**: If an entity's **Total Revenue** exceeds $25,000 USD per year OR the **Commercial Usage** period exceeds 3 months, the entity must request a commercial license from the author.
|
||||
|
||||
- **Requesting a Commercial License**: Entities that need to engage in **Commercial Usage** exceeding both thresholds must contact the author at kohaku@kblueleaf.net to request a commercial license. The author may grant such licenses at their sole discretion, potentially subject to fees, royalties, or revenue-sharing agreements.
|
||||
|
||||
### 2. Prohibited Uses
|
||||
|
||||
You may not use the Software for:
|
||||
|
||||
- Military purposes or weapons development
|
||||
- Surveillance systems or mass monitoring
|
||||
- Biometric identification or tracking systems
|
||||
- Any activity that infringes on third-party rights
|
||||
- Any use violating applicable laws, including privacy and security regulations
|
||||
- Generating or distributing malware, exploits, or other malicious software
|
||||
|
||||
You may not:
|
||||
|
||||
- Alter or remove copyright and proprietary notices
|
||||
- Circumvent or remove any security or usage restrictions
|
||||
- Impose additional terms that conflict with this License
|
||||
- Distribute the Software to prohibited individuals, entities, or countries as defined by applicable export laws
|
||||
|
||||
### 3. Distribution Requirements
|
||||
|
||||
When distributing the Software or any Derivative Works, you must:
|
||||
|
||||
- Include a copy of this License with the distribution
|
||||
- Include the complete Source Code or provide clear instructions on how to obtain it
|
||||
- **Attribution Notice**: Prominently display the following notice:
|
||||
|
||||
```
|
||||
This Software is licensed under the Kohaku Software License by KohakuBlueLeaf.
|
||||
Copyright 2025 KohakuBlueLeaf.
|
||||
|
||||
IN NO EVENT SHALL KohakuBlueLeaf BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
||||
LIABILITY ARISING FROM THE USE OF THIS SOFTWARE.
|
||||
```
|
||||
|
||||
- **For Derivative Works**:
|
||||
- Include a statement clearly indicating that you have modified the original Software
|
||||
- Document the nature of modifications made
|
||||
- Ensure all Source Code is available under this License
|
||||
|
||||
- **No Misrepresentation**: Do not misrepresent or imply that Derivative Works are official versions or have been endorsed by the original author unless authorized in writing.
|
||||
|
||||
- **Service Provider Requirements**:
|
||||
- **Service Providers** must provide **End Users** with clear notice that the service utilizes Software licensed under the Kohaku Software License
|
||||
- Include a reference to the original Software and this License in service documentation, terms of service, or user interface (e.g., "About" page, footer)
|
||||
|
||||
## No Harm and No Liability
|
||||
|
||||
### 1. No Harm
|
||||
|
||||
You agree that no contributor's conduct in creating the Software has caused you harm. To the extent permitted by law, you waive the right to pursue any legal claims against contributors related to the creation of the Software.
|
||||
|
||||
### 2. No Liability
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
## Patent Grant
|
||||
|
||||
Each contributor grants you a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, use, offer to sell, sell, import, and otherwise transfer the Software, where such license applies only to those patent claims licensable by such contributor that are necessarily infringed by their contribution(s) alone or by combination of their contribution(s) with the Software.
|
||||
|
||||
## Interpretation of Ambiguous Terms
|
||||
|
||||
In the event of any ambiguity or uncertainty in the interpretation of the terms of this License, the Licensee has the right to interpret the ambiguous descriptions in a manner that aligns with the intended purpose of this License, which is to promote open access while protecting sustainable development through commercial licensing.
|
||||
|
||||
## Acceptance and Compliance
|
||||
|
||||
By using, modifying, or distributing the Software, you agree to comply with all terms of this License. Non-compliance may result in the automatic termination of your rights under this License.
|
||||
|
||||
## Termination
|
||||
|
||||
Your rights under this License terminate automatically upon any breach of its terms. Upon termination, you must:
|
||||
|
||||
- Cease all use, modification, and distribution of the Software and Derivative Works
|
||||
- Destroy all copies of the Software in your possession or control
|
||||
- If you are a Service Provider, cease providing services that utilize the Software
|
||||
|
||||
Sections regarding No Liability, Indemnification, and General Provisions survive termination.
|
||||
|
||||
## Indemnification
|
||||
|
||||
You agree to indemnify, defend, and hold harmless KohakuBlueLeaf and its affiliates, contributors, and licensors from and against any claims, damages, losses, liabilities, costs, and expenses (including reasonable attorneys' fees) arising from:
|
||||
|
||||
- Your use of the Software
|
||||
- Your violation of this License
|
||||
- Your violation of any rights of another party
|
||||
- Your distribution of the Software or Derivative Works
|
||||
|
||||
## General Provisions
|
||||
|
||||
- **Governing Law**: This License is governed by the laws of Taiwan, without regard to conflict of law principles.
|
||||
|
||||
- **Severability**: If any provision of this License is held to be unenforceable or invalid, that provision shall be modified to the minimum extent necessary to make it enforceable, and the remaining provisions shall remain in full force and effect.
|
||||
|
||||
- **Entire Agreement**: This License constitutes the entire agreement between you and KohakuBlueLeaf regarding the Software and supersedes all prior agreements and understandings.
|
||||
|
||||
- **No Waiver**: The failure of KohakuBlueLeaf to enforce any provision of this License shall not constitute a waiver of that provision or any other provision.
|
||||
|
||||
- **Assignment**: You may not assign or transfer your rights or obligations under this License without prior written consent from KohakuBlueLeaf.
|
||||
|
||||
## Revisions
|
||||
|
||||
KohakuBlueLeaf may publish revised versions of the Kohaku Software License from time to time. Each version will be given a distinguishing version number. You may choose to use the Software under the terms of the version of the License under which you originally received the Software, or under the terms of any subsequent version published by KohakuBlueLeaf.
|
||||
|
||||
## Contact
|
||||
|
||||
For commercial licensing inquiries, please contact: kohaku@kblueleaf.net
|
||||
316
src/kohaku-hub-ui/src/components/DatasetViewer/README.md
Normal file
316
src/kohaku-hub-ui/src/components/DatasetViewer/README.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Dataset Viewer - Frontend Components
|
||||
|
||||
Vue 3 components for previewing dataset files.
|
||||
|
||||
## Components
|
||||
|
||||
### DatasetViewer.vue
|
||||
|
||||
Main container component that handles loading and displaying dataset previews.
|
||||
|
||||
**Props:**
|
||||
- `fileUrl` (String, required): S3 presigned URL or HTTP(S) URL
|
||||
- `fileName` (String, required): File name (for format detection)
|
||||
- `maxRows` (Number, default: 1000): Maximum rows to display
|
||||
|
||||
**Events:**
|
||||
- `@error`: Emitted when an error occurs
|
||||
|
||||
**Example:**
|
||||
```vue
|
||||
<script setup>
|
||||
import DatasetViewer from '@/components/DatasetViewer/DatasetViewer.vue'
|
||||
|
||||
const fileUrl = 'https://s3.amazonaws.com/bucket/data.csv?X-Amz-...'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DatasetViewer
|
||||
:file-url="fileUrl"
|
||||
:file-name="data.csv"
|
||||
:max-rows="1000"
|
||||
@error="handleError"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### DataGrid.vue
|
||||
|
||||
Tabular data display with sorting.
|
||||
|
||||
**Props:**
|
||||
- `columns` (Array, required): Column names
|
||||
- `rows` (Array, required): Row data (2D array)
|
||||
- `truncated` (Boolean): Whether data is truncated
|
||||
|
||||
**Features:**
|
||||
- Click column headers to sort
|
||||
- Ascending/descending toggle
|
||||
- Max height: 600px (scrollable)
|
||||
- Max cell width: 300px (ellipsis)
|
||||
|
||||
### TARFileList.vue
|
||||
|
||||
File browser for TAR archives.
|
||||
|
||||
**Props:**
|
||||
- `files` (Array, required): File list from backend
|
||||
|
||||
**Events:**
|
||||
- `@select`: Emitted when user selects a file
|
||||
|
||||
**Features:**
|
||||
- Grouped by directory
|
||||
- Search/filter
|
||||
- Shows file sizes
|
||||
- Only previewable files are clickable
|
||||
|
||||
## API Client (api.js)
|
||||
|
||||
```javascript
|
||||
import {
|
||||
previewFile,
|
||||
listTARFiles,
|
||||
extractTARFile,
|
||||
getRateLimitStats,
|
||||
detectFormat,
|
||||
formatBytes
|
||||
} from '@/components/DatasetViewer/api'
|
||||
```
|
||||
|
||||
### previewFile(url, options)
|
||||
|
||||
Preview a dataset file.
|
||||
|
||||
**Arguments:**
|
||||
- `url` (String): File URL
|
||||
- `options` (Object):
|
||||
- `format` (String): File format (auto-detect if omitted)
|
||||
- `maxRows` (Number): Max rows to return
|
||||
- `delimiter` (String): CSV delimiter
|
||||
|
||||
**Returns:** Promise<Object>
|
||||
```javascript
|
||||
{
|
||||
columns: ['col1', 'col2'],
|
||||
rows: [['val1', 'val2']],
|
||||
total_rows: 1,
|
||||
truncated: false,
|
||||
file_size: 1024,
|
||||
format: 'csv'
|
||||
}
|
||||
```
|
||||
|
||||
### listTARFiles(url)
|
||||
|
||||
List files in TAR archive.
|
||||
|
||||
**Returns:** Promise<Object>
|
||||
```javascript
|
||||
{
|
||||
files: [
|
||||
{ name: 'train.csv', size: 10240, offset: 512 }
|
||||
],
|
||||
total_size: 20480
|
||||
}
|
||||
```
|
||||
|
||||
### extractTARFile(url, fileName)
|
||||
|
||||
Extract file from TAR archive.
|
||||
|
||||
**Returns:** Promise<Blob>
|
||||
|
||||
### getRateLimitStats()
|
||||
|
||||
Get rate limit statistics.
|
||||
|
||||
**Returns:** Promise<Object>
|
||||
```javascript
|
||||
{
|
||||
requests_used: 10,
|
||||
requests_limit: 60,
|
||||
concurrent_requests: 1,
|
||||
concurrent_limit: 3,
|
||||
bytes_processed: 1048576,
|
||||
window_seconds: 60
|
||||
}
|
||||
```
|
||||
|
||||
### detectFormat(filename)
|
||||
|
||||
Detect file format from filename.
|
||||
|
||||
**Returns:** String | null
|
||||
|
||||
Supported formats: `csv`, `tsv`, `json`, `jsonl`, `parquet`, `tar`
|
||||
|
||||
### formatBytes(bytes)
|
||||
|
||||
Format bytes to human-readable string.
|
||||
|
||||
**Returns:** String (e.g., "1.5 MB")
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Basic Preview
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import DatasetViewer from '@/components/DatasetViewer/DatasetViewer.vue'
|
||||
import { repoAPI } from '@/utils/api'
|
||||
|
||||
const props = defineProps(['namespace', 'name', 'path'])
|
||||
|
||||
// Get presigned URL from KohakuHub API
|
||||
const fileUrl = ref(null)
|
||||
|
||||
async function loadFile() {
|
||||
const response = await repoAPI.downloadFile(
|
||||
'dataset',
|
||||
props.namespace,
|
||||
props.name,
|
||||
'main',
|
||||
props.path
|
||||
)
|
||||
fileUrl.value = response.request.responseURL // Follow redirect to get presigned URL
|
||||
}
|
||||
|
||||
loadFile()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="fileUrl">
|
||||
<DatasetViewer
|
||||
:file-url="fileUrl"
|
||||
:file-name="path"
|
||||
:max-rows="1000"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Integrated into Repo Viewer
|
||||
|
||||
```vue
|
||||
<!-- In pages/[type]s/[namespace]/[name]/index.vue -->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import DatasetViewer from '@/components/DatasetViewer/DatasetViewer.vue'
|
||||
import { repoAPI } from '@/utils/api'
|
||||
|
||||
const route = useRoute()
|
||||
const selectedFile = ref(null)
|
||||
|
||||
// Get presigned URL for file
|
||||
async function previewFile(file) {
|
||||
const url = `/${route.params.type}s/${route.params.namespace}/${route.params.name}/resolve/main/${file.path}`
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
selectedFile.value = {
|
||||
name: file.path,
|
||||
url: response.url
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="repo-viewer">
|
||||
<!-- File tree -->
|
||||
<div class="file-tree">
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.path"
|
||||
@click="previewFile(file)"
|
||||
>
|
||||
{{ file.path }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dataset viewer -->
|
||||
<div v-if="selectedFile" class="preview">
|
||||
<DatasetViewer
|
||||
:file-url="selectedFile.url"
|
||||
:file-name="selectedFile.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
All components support dark mode out of the box:
|
||||
```vue
|
||||
<div class="bg-white dark:bg-gray-900 text-black dark:text-white">
|
||||
```
|
||||
|
||||
Customize with CSS:
|
||||
```css
|
||||
.dataset-viewer {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.data-grid-container {
|
||||
max-height: 800px; /* Increase max height */
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const error = ref(null)
|
||||
|
||||
function handleError(err) {
|
||||
error.value = err
|
||||
console.error('Dataset viewer error:', err)
|
||||
|
||||
// Show notification
|
||||
ElMessage.error({
|
||||
message: `Failed to load preview: ${err}`,
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DatasetViewer
|
||||
:file-url="url"
|
||||
:file-name="name"
|
||||
@error="handleError"
|
||||
/>
|
||||
|
||||
<div v-if="error" class="error-banner">
|
||||
{{ error }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Limit max_rows**: Default 1000 is good balance
|
||||
2. **Lazy load**: Only render viewer when file is selected
|
||||
3. **Cancel requests**: Use AbortController for navigation
|
||||
4. **Cache URLs**: Reuse presigned URLs (valid for 1 hour)
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- Chrome: ✅ Full support
|
||||
- Firefox: ✅ Full support
|
||||
- Safari: ✅ Full support
|
||||
- Edge: ✅ Full support
|
||||
|
||||
Requires modern browser with fetch() and async/await support.
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Free for commercial and non-commercial use.
|
||||
|
||||
---
|
||||
|
||||
**Questions?** Check the backend README or open an issue!
|
||||
137
src/kohaku-hub-ui/src/components/DatasetViewer/TARFileList.vue
Normal file
137
src/kohaku-hub-ui/src/components/DatasetViewer/TARFileList.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { formatBytes, detectFormat } from "./api";
|
||||
|
||||
const props = defineProps({
|
||||
files: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select"]);
|
||||
|
||||
const filter = ref("");
|
||||
|
||||
// Filter files
|
||||
const filteredFiles = computed(() => {
|
||||
if (!filter.value) return props.files;
|
||||
|
||||
const query = filter.value.toLowerCase();
|
||||
return props.files.filter((f) => f.name.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
// Group files by directory
|
||||
const groupedFiles = computed(() => {
|
||||
const groups = {};
|
||||
|
||||
for (const file of filteredFiles.value) {
|
||||
const parts = file.name.split("/");
|
||||
const dir = parts.length > 1 ? parts.slice(0, -1).join("/") : "/";
|
||||
|
||||
if (!groups[dir]) {
|
||||
groups[dir] = [];
|
||||
}
|
||||
|
||||
groups[dir].push({
|
||||
...file,
|
||||
basename: parts[parts.length - 1],
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
// Check if file is previewable
|
||||
function isPreviewable(fileName) {
|
||||
const format = detectFormat(fileName);
|
||||
return format && format !== "tar";
|
||||
}
|
||||
|
||||
// Handle file selection
|
||||
function selectFile(file) {
|
||||
if (!isPreviewable(file.name)) {
|
||||
return;
|
||||
}
|
||||
emit("select", file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tar-file-list">
|
||||
<!-- Header -->
|
||||
<div class="header p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h4 class="text-lg font-semibold mb-2">Archive Contents</h4>
|
||||
<input
|
||||
v-model="filter"
|
||||
type="text"
|
||||
placeholder="Filter files..."
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-black dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<div class="file-list overflow-auto" style="max-height: 500px">
|
||||
<div
|
||||
v-for="(files, dir) in groupedFiles"
|
||||
:key="dir"
|
||||
class="directory-group"
|
||||
>
|
||||
<!-- Directory header -->
|
||||
<div
|
||||
class="directory-header px-4 py-2 bg-gray-100 dark:bg-gray-800 text-sm font-semibold sticky top-0"
|
||||
>
|
||||
📁 {{ dir }}
|
||||
</div>
|
||||
|
||||
<!-- Files in directory -->
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.name"
|
||||
class="file-item px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
:class="{
|
||||
'cursor-pointer': isPreviewable(file.name),
|
||||
'opacity-50 cursor-not-allowed': !isPreviewable(file.name),
|
||||
}"
|
||||
@click="selectFile(file)"
|
||||
>
|
||||
<div class="file-info flex-1">
|
||||
<div class="file-name font-medium">
|
||||
{{ file.basename }}
|
||||
</div>
|
||||
<div class="file-size text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ formatBytes(file.size) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isPreviewable(file.name)"
|
||||
class="preview-badge px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded text-xs"
|
||||
>
|
||||
Preview →
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="not-previewable text-xs text-gray-500 dark:text-gray-500"
|
||||
>
|
||||
Not previewable
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="filteredFiles.length === 0"
|
||||
class="empty-state p-8 text-center text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
No files found
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-item {
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
</style>
|
||||
145
src/kohaku-hub-ui/src/components/DatasetViewer/api.js
Normal file
145
src/kohaku-hub-ui/src/components/DatasetViewer/api.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Dataset Viewer API Client
|
||||
*
|
||||
* Minimal API client for dataset preview backend.
|
||||
* No authentication required - relies on S3 presigned URLs.
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
const API_BASE = "/api/dataset-viewer";
|
||||
|
||||
/**
|
||||
* Preview a dataset file
|
||||
*
|
||||
* @param {string} url - S3 presigned URL or any HTTP(S) URL
|
||||
* @param {Object} options - Preview options
|
||||
* @param {string} options.format - File format (csv, json, jsonl, parquet, tar)
|
||||
* @param {number} options.maxRows - Maximum rows to return (default: 1000)
|
||||
* @param {string} options.delimiter - CSV delimiter (default: ",")
|
||||
* @returns {Promise<Object>} Preview data
|
||||
*/
|
||||
export async function previewFile(url, options = {}) {
|
||||
const { format, maxRows = 1000, delimiter = "," } = options;
|
||||
|
||||
const response = await axios.post(`${API_BASE}/preview`, {
|
||||
url,
|
||||
format,
|
||||
max_rows: maxRows,
|
||||
delimiter,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in TAR archive
|
||||
*
|
||||
* @param {string} url - TAR file URL
|
||||
* @returns {Promise<Object>} File listing
|
||||
*/
|
||||
export async function listTARFiles(url) {
|
||||
const response = await axios.post(`${API_BASE}/tar/list`, { url });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file from TAR archive
|
||||
*
|
||||
* @param {string} url - TAR file URL
|
||||
* @param {string} fileName - File name to extract
|
||||
* @returns {Promise<Blob>} File content
|
||||
*/
|
||||
export async function extractTARFile(url, fileName) {
|
||||
const response = await axios.post(
|
||||
`${API_BASE}/tar/extract`,
|
||||
{
|
||||
url,
|
||||
file_name: fileName,
|
||||
},
|
||||
{
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute SQL query on dataset
|
||||
*
|
||||
* @param {string} url - Dataset file URL
|
||||
* @param {string} query - SQL query to execute
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} options.format - File format
|
||||
* @param {number} options.maxRows - Max rows to return
|
||||
* @returns {Promise<Object>} Query results
|
||||
*/
|
||||
export async function executeSQLQuery(url, query, options = {}) {
|
||||
const { format, maxRows = 10000 } = options;
|
||||
|
||||
const response = await axios.post(`${API_BASE}/sql`, {
|
||||
url,
|
||||
query, // Query in body, not URL!
|
||||
format,
|
||||
max_rows: maxRows,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit statistics
|
||||
*
|
||||
* @returns {Promise<Object>} Rate limit stats
|
||||
*/
|
||||
export async function getRateLimitStats() {
|
||||
const response = await axios.get(`${API_BASE}/rate-limit`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect file format from filename
|
||||
*
|
||||
* @param {string} filename - File name
|
||||
* @returns {string|null} Format (csv, jsonl, parquet, tar) or null
|
||||
*
|
||||
* Note: JSON format is NOT supported (requires loading entire file).
|
||||
* Use JSONL instead for streaming support.
|
||||
*/
|
||||
export function detectFormat(filename) {
|
||||
const lower = filename.toLowerCase();
|
||||
|
||||
if (lower.endsWith(".csv")) return "csv";
|
||||
if (lower.endsWith(".tsv")) return "tsv";
|
||||
if (lower.endsWith(".jsonl") || lower.endsWith(".ndjson")) return "jsonl";
|
||||
if (lower.endsWith(".parquet")) return "parquet";
|
||||
if (
|
||||
lower.endsWith(".tar") ||
|
||||
lower.endsWith(".tar.gz") ||
|
||||
lower.endsWith(".tgz") ||
|
||||
lower.endsWith(".tar.bz2")
|
||||
) {
|
||||
return "tar";
|
||||
}
|
||||
// JSON format deliberately excluded - requires full file download
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable string
|
||||
*
|
||||
* @param {number} bytes - Bytes
|
||||
* @returns {string} Formatted string (e.g., "1.5 MB")
|
||||
*/
|
||||
export function formatBytes(bytes) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
if (!bytes) return "Unknown";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
433
src/kohaku-hub-ui/src/components/repo/DatasetViewerTab.vue
Normal file
433
src/kohaku-hub-ui/src/components/repo/DatasetViewerTab.vue
Normal file
@@ -0,0 +1,433 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import DatasetViewer from "@/components/DatasetViewer/DatasetViewer.vue";
|
||||
import { detectFormat } from "@/components/DatasetViewer/api";
|
||||
|
||||
const props = defineProps({
|
||||
repoType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
namespace: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
branch: {
|
||||
type: String,
|
||||
default: "main",
|
||||
},
|
||||
files: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedFile = ref(null);
|
||||
const fileUrl = ref(null);
|
||||
const loadingUrl = ref(false);
|
||||
|
||||
// Query parameters
|
||||
const maxRows = ref(100);
|
||||
const sqlQuery = ref("SELECT * FROM dataset LIMIT 100");
|
||||
const useSQL = ref(false);
|
||||
const sqlWarning = ref("");
|
||||
|
||||
// Trigger key to force reload
|
||||
const reloadKey = ref(0);
|
||||
|
||||
// Check if SQL is supported for current file
|
||||
const sqlSupported = computed(() => {
|
||||
if (!selectedFile.value) return false;
|
||||
const format = detectFormat(selectedFile.value.path);
|
||||
return format === "csv" || format === "parquet";
|
||||
});
|
||||
|
||||
// Validate and apply query
|
||||
function applyQuery() {
|
||||
sqlWarning.value = "";
|
||||
|
||||
// If using SQL, check for LIMIT clause
|
||||
if (useSQL.value && sqlQuery.value.trim()) {
|
||||
const queryUpper = sqlQuery.value.toUpperCase();
|
||||
if (!queryUpper.includes("LIMIT")) {
|
||||
sqlWarning.value =
|
||||
"No LIMIT found - automatically adding LIMIT 10000 for safety";
|
||||
// Backend will add LIMIT automatically
|
||||
}
|
||||
}
|
||||
|
||||
reloadKey.value++;
|
||||
}
|
||||
|
||||
// Filter previewable files
|
||||
const previewableFiles = computed(() => {
|
||||
return props.files
|
||||
.filter((file) => file.type !== "directory")
|
||||
.filter((file) => {
|
||||
const format = detectFormat(file.path);
|
||||
return format && format !== "tar"; // Exclude TAR for now
|
||||
})
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
});
|
||||
|
||||
// Group files by directory
|
||||
const groupedFiles = computed(() => {
|
||||
const groups = {};
|
||||
|
||||
for (const file of previewableFiles.value) {
|
||||
const parts = file.path.split("/");
|
||||
const dir = parts.length > 1 ? parts.slice(0, -1).join("/") : "/";
|
||||
|
||||
if (!groups[dir]) {
|
||||
groups[dir] = [];
|
||||
}
|
||||
|
||||
groups[dir].push({
|
||||
...file,
|
||||
basename: parts[parts.length - 1],
|
||||
format: detectFormat(file.path),
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
// Select a file and get presigned URL
|
||||
async function selectFile(file) {
|
||||
selectedFile.value = file;
|
||||
loadingUrl.value = true;
|
||||
fileUrl.value = null;
|
||||
|
||||
try {
|
||||
// Get presigned URL by making HEAD request (follows redirect)
|
||||
const url = `/${props.repoType}s/${props.namespace}/${props.name}/resolve/${props.branch}/${file.path}`;
|
||||
|
||||
const response = await fetch(url, { method: "HEAD" });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get file URL: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Get the final URL after redirect
|
||||
fileUrl.value = response.url;
|
||||
} catch (err) {
|
||||
ElMessage.error({
|
||||
message: `Failed to load file: ${err.message}`,
|
||||
duration: 5000,
|
||||
});
|
||||
selectedFile.value = null;
|
||||
} finally {
|
||||
loadingUrl.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error from DatasetViewer
|
||||
function handleViewerError(error) {
|
||||
ElMessage.error({
|
||||
message: `Preview error: ${error}`,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatSize(bytes) {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
|
||||
// Auto-select first file if available
|
||||
watch(
|
||||
() => previewableFiles.value,
|
||||
(files) => {
|
||||
if (files.length > 0 && !selectedFile.value) {
|
||||
selectFile(files[0]);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dataset-viewer-tab">
|
||||
<!-- Full width layout: File list on left, Viewer on right -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-6">
|
||||
<!-- File List Sidebar -->
|
||||
<div class="file-sidebar">
|
||||
<div class="card p-0 h-fit">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-base font-semibold">Previewable Files</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ previewableFiles.length }} file{{
|
||||
previewableFiles.length !== 1 ? "s" : ""
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- File List (Scrollable) -->
|
||||
<div
|
||||
class="file-list-scroll overflow-y-auto"
|
||||
style="max-height: 300px"
|
||||
>
|
||||
<!-- No files -->
|
||||
<div
|
||||
v-if="previewableFiles.length === 0"
|
||||
class="text-center py-8 px-4 text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<div class="i-carbon-document-blank text-4xl mb-2 inline-block" />
|
||||
<p class="text-sm">No previewable files</p>
|
||||
<p class="text-xs mt-1">Supported: CSV, JSON, JSONL, Parquet</p>
|
||||
</div>
|
||||
|
||||
<!-- File groups -->
|
||||
<div v-else class="py-2">
|
||||
<div
|
||||
v-for="(files, dir) in groupedFiles"
|
||||
:key="dir"
|
||||
class="file-group mb-2"
|
||||
>
|
||||
<!-- Directory header -->
|
||||
<div
|
||||
v-if="dir !== '/'"
|
||||
class="text-xs font-semibold text-gray-500 dark:text-gray-400 px-3 py-1"
|
||||
>
|
||||
<div class="i-carbon-folder inline-block mr-1" />
|
||||
{{ dir }}
|
||||
</div>
|
||||
|
||||
<!-- Files in directory -->
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.path"
|
||||
class="file-item px-3 py-2 cursor-pointer transition-all"
|
||||
:class="[
|
||||
selectedFile?.path === file.path
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
]"
|
||||
@click="selectFile(file)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">
|
||||
{{ file.basename }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatSize(file.size) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="px-2 py-0.5 bg-gray-200 dark:bg-gray-700 rounded text-xs font-mono flex-shrink-0"
|
||||
>
|
||||
{{ file.format.toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Query Parameters -->
|
||||
<div
|
||||
class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-3"
|
||||
>
|
||||
<!-- SQL Mode Toggle (only for CSV/Parquet) -->
|
||||
<div v-if="sqlSupported">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="useSQL"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Use SQL Query
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- SQL Query Input -->
|
||||
<div v-if="useSQL && sqlSupported">
|
||||
<label
|
||||
class="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1"
|
||||
>
|
||||
SQL Query
|
||||
</label>
|
||||
<textarea
|
||||
v-model="sqlQuery"
|
||||
placeholder="SELECT * FROM dataset LIMIT 100"
|
||||
rows="4"
|
||||
class="w-full px-2 py-1.5 text-xs font-mono border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-black dark:text-white resize-none"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Use 'dataset' as table name
|
||||
</div>
|
||||
|
||||
<!-- SQL Warning -->
|
||||
<div
|
||||
v-if="sqlWarning"
|
||||
class="mt-2 p-2 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded text-xs text-yellow-800 dark:text-yellow-200"
|
||||
>
|
||||
⚠️ {{ sqlWarning }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simple mode (no SQL) - Show max rows selector -->
|
||||
<div v-if="!useSQL || !sqlSupported">
|
||||
<label
|
||||
class="text-xs font-semibold text-gray-700 dark:text-gray-300 block mb-1"
|
||||
>
|
||||
Max Rows
|
||||
</label>
|
||||
<select
|
||||
v-model.number="maxRows"
|
||||
class="w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-black dark:text-white"
|
||||
>
|
||||
<option :value="100">100 rows</option>
|
||||
<option :value="500">500 rows</option>
|
||||
<option :value="1000">1,000 rows</option>
|
||||
<option :value="5000">5,000 rows</option>
|
||||
<option :value="10000">10,000 rows</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Apply Button -->
|
||||
<button
|
||||
@click="applyQuery"
|
||||
class="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded font-medium text-sm transition-colors"
|
||||
:disabled="useSQL && !sqlQuery.trim()"
|
||||
>
|
||||
{{ useSQL ? "Run Query" : "Reload (First 100 Rows)" }}
|
||||
</button>
|
||||
|
||||
<!-- Quick query examples (SQL mode) -->
|
||||
<div v-if="useSQL && sqlSupported" class="text-xs space-y-1">
|
||||
<div class="font-semibold text-gray-600 dark:text-gray-400">
|
||||
Quick queries:
|
||||
</div>
|
||||
<button
|
||||
@click="sqlQuery = 'SELECT * FROM dataset LIMIT 100'"
|
||||
class="block w-full text-left px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
First 100 rows
|
||||
</button>
|
||||
<button
|
||||
@click="
|
||||
sqlQuery = 'SELECT * FROM dataset ORDER BY RANDOM() LIMIT 100'
|
||||
"
|
||||
class="block w-full text-left px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Random 100 rows
|
||||
</button>
|
||||
<button
|
||||
@click="sqlQuery = 'SELECT COUNT(*) as total FROM dataset'"
|
||||
class="block w-full text-left px-2 py-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Count all rows
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Viewer Panel (Full Width) -->
|
||||
<div class="viewer-panel min-w-0">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loadingUrl" class="card text-center py-20">
|
||||
<el-icon class="is-loading" :size="40">
|
||||
<div class="i-carbon-loading" />
|
||||
</el-icon>
|
||||
<p class="mt-4 text-gray-600 dark:text-gray-400">
|
||||
Loading file URL...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- No file selected -->
|
||||
<div
|
||||
v-else-if="!selectedFile"
|
||||
class="card text-center py-20 text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<div class="i-carbon-data-table text-6xl mb-4 inline-block" />
|
||||
<p>Select a file to preview</p>
|
||||
</div>
|
||||
|
||||
<!-- Dataset Viewer -->
|
||||
<DatasetViewer
|
||||
v-else-if="fileUrl"
|
||||
:key="reloadKey"
|
||||
:file-url="fileUrl"
|
||||
:file-name="selectedFile.path"
|
||||
:max-rows="maxRows"
|
||||
:sql-query="sqlQuery"
|
||||
:use-s-q-l="useSQL"
|
||||
@error="handleViewerError"
|
||||
@warning="(msg) => (sqlWarning = msg)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attribution Notice (Kohaku License requirement) -->
|
||||
<div
|
||||
class="mt-6 p-3 bg-gray-50 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="i-carbon-information" />
|
||||
<span class="font-semibold">Dataset Viewer</span>
|
||||
</div>
|
||||
<p class="text-xs">
|
||||
Licensed under Kohaku Software License by KohakuBlueLeaf. Commercial
|
||||
usage exceeding trial limits ($25k/year OR 3 months) requires a
|
||||
commercial license.
|
||||
<a
|
||||
href="mailto:kohaku@kblueleaf.net"
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Contact for licensing
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-item {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.file-list-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(209 213 219) transparent;
|
||||
}
|
||||
|
||||
.dark .file-list-scroll {
|
||||
scrollbar-color: rgb(75 85 99) transparent;
|
||||
}
|
||||
|
||||
.file-list-scroll::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.file-list-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.file-list-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(209 213 219);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dark .file-list-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(75 85 99);
|
||||
}
|
||||
</style>
|
||||
@@ -25,11 +25,18 @@
|
||||
<el-button @click="$router.back()">Go Back</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-6">
|
||||
<div
|
||||
v-else
|
||||
:class="
|
||||
activeTab === 'viewer'
|
||||
? ''
|
||||
: 'grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-6'
|
||||
"
|
||||
>
|
||||
<!-- Main Content -->
|
||||
<main class="min-w-0">
|
||||
<!-- Repo Header -->
|
||||
<div class="card mb-6">
|
||||
<!-- Repo Header (hidden for viewer tab) -->
|
||||
<div v-if="activeTab !== 'viewer'" class="card mb-6">
|
||||
<div
|
||||
class="flex flex-col sm:flex-row items-start justify-between gap-4 mb-4"
|
||||
>
|
||||
@@ -165,9 +172,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Header (Key badges) -->
|
||||
<!-- Metadata Header (Key badges) (hidden for viewer tab) -->
|
||||
<MetadataHeader
|
||||
v-if="hasMetadataHeader"
|
||||
v-if="hasMetadataHeader && activeTab !== 'viewer'"
|
||||
:metadata="readmeMetadata"
|
||||
:repo-type="repoType"
|
||||
@navigate-to-metadata="navigateToTab('metadata')"
|
||||
@@ -235,6 +242,19 @@
|
||||
>
|
||||
Metadata
|
||||
</button>
|
||||
<button
|
||||
v-if="repoType === 'dataset'"
|
||||
:class="[
|
||||
'px-4 py-2 font-medium transition-colors',
|
||||
activeTab === 'viewer'
|
||||
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200',
|
||||
]"
|
||||
@click="navigateToTab('viewer')"
|
||||
>
|
||||
<div class="i-carbon-data-table inline-block mr-1" />
|
||||
Viewer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -295,6 +315,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Viewer Tab (for datasets only) -->
|
||||
<div v-if="activeTab === 'viewer' && repoType === 'dataset'">
|
||||
<DatasetViewerTab
|
||||
:repo-type="repoType"
|
||||
:namespace="namespace"
|
||||
:name="name"
|
||||
:branch="currentBranch"
|
||||
:files="fileTree"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'files'" class="card">
|
||||
<div
|
||||
class="mb-4 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3"
|
||||
@@ -552,7 +583,10 @@
|
||||
</main>
|
||||
|
||||
<!-- Sidebar (Compact) -->
|
||||
<aside class="space-y-4 lg:sticky lg:top-20 lg:self-start">
|
||||
<aside
|
||||
v-if="activeTab !== 'viewer'"
|
||||
class="space-y-4 lg:sticky lg:top-20 lg:self-start"
|
||||
>
|
||||
<!-- Relationships (Author + Base Model + Datasets from YAML) -->
|
||||
<SidebarRelationshipsCard
|
||||
:namespace="namespace"
|
||||
@@ -759,6 +793,7 @@ import MetadataHeader from "@/components/repo/metadata/MetadataHeader.vue";
|
||||
import DetailedMetadataPanel from "@/components/repo/metadata/DetailedMetadataPanel.vue";
|
||||
import ReferencedDatasetsCard from "@/components/repo/metadata/ReferencedDatasetsCard.vue";
|
||||
import SidebarRelationshipsCard from "@/components/repo/metadata/SidebarRelationshipsCard.vue";
|
||||
import DatasetViewerTab from "@/components/repo/DatasetViewerTab.vue";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
@@ -975,6 +1010,12 @@ function navigateToTab(tab) {
|
||||
query: { tab: "metadata" },
|
||||
});
|
||||
break;
|
||||
case "viewer":
|
||||
router.push({
|
||||
path: `/${props.repoType}s/${props.namespace}/${props.name}`,
|
||||
query: { tab: "viewer" },
|
||||
});
|
||||
break;
|
||||
default:
|
||||
router.push(`/${props.repoType}s/${props.namespace}/${props.name}`);
|
||||
}
|
||||
@@ -1424,6 +1465,8 @@ onMounted(async () => {
|
||||
} else if (activeTab.value === "card") {
|
||||
await loadFileTree();
|
||||
await loadReadme();
|
||||
} else if (activeTab.value === "viewer") {
|
||||
await loadFileTree();
|
||||
} else if (activeTab.value === "commits") {
|
||||
await loadCommits();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user