Files
seaweedfs/weed/admin/view/app/iceberg_table_details.templ
T
Chris Lu 60e7b30009 admin: browse Iceberg table data (#10227)
* admin: move volume-server read JWT helper into dash

The Iceberg data preview page needs the same per-fileId read token the
file browser uses when streaming chunks from volume servers.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: add Iceberg table data preview page

The admin UI browses the Iceberg catalog down to table details but not
the data itself. Add a Browse Data page per table that walks the
selected snapshot's manifests and shows sample rows from its Parquet
data files, plus the data file list with per-file preview, a snapshot
switcher, and a row limit selector.

Rows are read through a ranged ReaderAt over stream-content so only
the Parquet footer and needed pages are fetched, with the volume read
JWT applied when configured. Iceberg locations resolve into /buckets
with traversal guards, and the file parameter must match a
manifest-listed data file. Snapshots with delete files get a warning
that raw rows are shown.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: integration test for Iceberg catalog and data preview pages

Starts a weed mini cluster with the admin UI, creates a table bucket,
namespace, and tables via the S3 Tables manager, uploads real Parquet
files via S3, writes manifests and snapshots with iceberg-go, and
asserts on the rendered pages: catalog browsing, table details,
current and historical snapshot previews, per-file preview, row
limits, unknown snapshot and file errors, and a metadata-less table.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: write Iceberg preview chunk reads straight into the caller slice

ReadAt wrapped the caller's buffer in a bytes.Buffer, which would
silently allocate a fresh backing array and drop bytes if it ever grew.
Copy directly into the destination slice and reject negative offsets so
the ReaderAt contract holds.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* admin: link to snapshot history when the preview switcher truncates

The snapshot switcher caps at 25 entries; add a trailing item pointing
at the table details page so older snapshots stay reachable.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur

* test: hoist mini cluster context assignment out of the goroutine

Set MiniClusterCtx before launching the cluster goroutine and clear it
in stop(), so the assignment is not buried in the command loop.

Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur
2026-07-03 14:02:44 -07:00

395 lines
11 KiB
Templ

package app
import (
"fmt"
"net/url"
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
)
templ IcebergTableDetails(data dash.IcebergTableDetailsData) {
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item">
<a href={ dash.PUrl(ctx, "/object-store/s3tables/buckets") }>
<i class="fas fa-table me-1"></i>Table Buckets
</a>
</li>
<li class="breadcrumb-item">
<a href={ dash.PUrl(ctx, fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces", url.PathEscape(data.CatalogName))) }>
{ data.CatalogName }
</a>
</li>
<li class="breadcrumb-item">
<a href={ dash.PUrl(ctx, fmt.Sprintf("/object-store/s3tables/buckets/%s/namespaces/%s/tables", url.PathEscape(data.CatalogName), url.PathEscape(data.NamespaceName))) }>
{ data.NamespaceName }
</a>
</li>
<li class="breadcrumb-item active">{ data.TableName }</li>
</ol>
</nav>
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<a class="btn btn-sm btn-primary" href={ dash.PUrl(ctx, icebergTableDataURL(data.CatalogName, data.NamespaceName, data.TableName, 0, 0, "")) }>
<i class="fas fa-table me-1"></i>Browse Data
</a>
</div>
<div class="btn-group me-2">
<button type="button" class="btn btn-sm btn-danger iceberg-delete-table-btn" data-bucket-arn={ data.BucketARN } data-namespace={ data.NamespaceName } data-table-name={ data.TableName } data-catalog-name={ data.CatalogName }>
<i class="fas fa-trash me-1"></i>Drop Table
</button>
</div>
</div>
</div>
if data.MetadataError != "" {
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>{ data.MetadataError }
</div>
}
<div class="row mb-4">
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Data Files
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
if data.HasDataFileCount {
{ formatNumber(data.DataFileCount) }
} else {
-
}
</div>
</div>
<div class="col-auto">
<i class="fas fa-copy fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
Total Size
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
if data.HasTotalSize {
{ formatBytes(data.TotalSizeBytes) }
} else {
-
}
</div>
</div>
<div class="col-auto">
<i class="fas fa-database fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card border-left-info shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
Snapshots
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">
if data.HasSnapshotCount {
{ formatNumber(int64(data.SnapshotCount)) }
} else {
-
}
</div>
</div>
<div class="col-auto">
<i class="fas fa-history fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card shadow h-100">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-info-circle me-2"></i>Table Metadata
</h6>
</div>
<div class="card-body">
<table class="table table-sm">
<tbody>
<tr>
<th>Table ARN</th>
<td class="text-muted small">{ data.TableARN }</td>
</tr>
<tr>
<th>Format</th>
<td>{ data.Format }</td>
</tr>
<tr>
<th>Table Location</th>
<td>
if data.TableLocation != "" {
<code class="small">{ data.TableLocation }</code>
} else {
<span class="text-muted">-</span>
}
</td>
</tr>
<tr>
<th>Metadata Location</th>
<td>
if data.MetadataLocation != "" {
<code class="small">{ data.MetadataLocation }</code>
} else {
<span class="text-muted">-</span>
}
</td>
</tr>
<tr>
<th>Created</th>
<td>
if !data.CreatedAt.IsZero() {
{ data.CreatedAt.Format("2006-01-02 15:04") }
} else {
<span class="text-muted">-</span>
}
</td>
</tr>
<tr>
<th>Modified</th>
<td>
if !data.ModifiedAt.IsZero() {
{ data.ModifiedAt.Format("2006-01-02 15:04") }
} else {
<span class="text-muted">-</span>
}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card shadow h-100">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-sliders-h me-2"></i>Properties
</h6>
</div>
<div class="card-body">
<table class="table table-sm">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
for _, prop := range data.Properties {
<tr>
<td>{ prop.Key }</td>
<td>{ prop.Value }</td>
</tr>
}
if len(data.Properties) == 0 {
<tr>
<td colspan="2" class="text-center text-muted">No properties available.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12 mb-4">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list me-2"></i>Schema
</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Type</th>
<th>Required</th>
</tr>
</thead>
<tbody>
for _, field := range data.SchemaFields {
<tr>
<td>{ fmt.Sprintf("%d", field.ID) }</td>
<td>{ field.Name }</td>
<td><code>{ string(field.Type) }</code></td>
<td>
if field.Required {
<span class="badge bg-success">Yes</span>
} else {
<span class="badge bg-secondary">No</span>
}
</td>
</tr>
}
if len(data.SchemaFields) == 0 {
<tr>
<td colspan="4" class="text-center text-muted">No schema available.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12 mb-4">
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-layer-group me-2"></i>Partitions
</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th>Transform</th>
<th>Source ID</th>
<th>Field ID</th>
</tr>
</thead>
<tbody>
for _, field := range data.PartitionFields {
<tr>
<td>{ field.Name }</td>
<td>{ field.Transform }</td>
<td>{ fmt.Sprintf("%d", field.SourceID) }</td>
<td>{ fmt.Sprintf("%d", field.FieldID) }</td>
</tr>
}
if len(data.PartitionFields) == 0 {
<tr>
<td colspan="4" class="text-center text-muted">No partitions defined.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-history me-2"></i>Snapshot History
</h6>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Snapshot ID</th>
<th>Timestamp</th>
<th>Operation</th>
<th>Manifest List</th>
</tr>
</thead>
<tbody>
for _, snapshot := range data.Snapshots {
<tr>
<td>{ fmt.Sprintf("%d", snapshot.SnapshotID) }</td>
<td>
if snapshot.Timestamp.IsZero() {
<span class="text-muted">-</span>
} else {
{ snapshot.Timestamp.Format("2006-01-02 15:04") }
}
</td>
<td>
if snapshot.Operation != "" {
{ snapshot.Operation }
} else {
<span class="text-muted">-</span>
}
</td>
<td>
if snapshot.ManifestList != "" {
<code class="small">{ snapshot.ManifestList }</code>
} else {
<span class="text-muted">-</span>
}
</td>
</tr>
}
if len(data.Snapshots) == 0 {
<tr>
<td colspan="4" class="text-center text-muted">No snapshots available.</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="deleteIcebergTableModal" tabindex="-1" aria-labelledby="deleteIcebergTableModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteIcebergTableModalLabel">
<i class="fas fa-exclamation-triangle me-2 text-warning"></i>Drop Table
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to drop the table <strong id="deleteIcebergTableName"></strong>?</p>
<div class="mb-3">
<label for="deleteIcebergTableVersion" class="form-label">Version Token (optional)</label>
<input type="text" class="form-control" id="deleteIcebergTableVersion" placeholder="Version token"/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="deleteIcebergTable()">
<i class="fas fa-trash me-1"></i>Drop Table
</button>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
initIcebergTableDetails();
});
</script>
}