Compare commits

...

11 Commits

Author SHA1 Message Date
e4468d305e Improve Blossom UI responsiveness and layout (v0.37.2)
Some checks failed
Go / build-and-release (push) Has been cancelled
- Show full npub on screens > 720px, truncated on smaller screens
- Make admin users list extend to full width

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 13:20:49 +01:00
d3f2ea0f08 Fix Blossom view layout overflow (v0.37.1)
Some checks failed
Go / build-and-release (push) Has been cancelled
- Use box-sizing instead of explicit width to fix right edge overflow

Files modified:
- pkg/version/version: Bump to v0.37.1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 13:15:13 +01:00
3f07e47ffb Fix Blossom view right edge overflow 2025-12-25 13:10:44 +01:00
aea8fd31e7 Improve Blossom UI with thumbnails and full-width layout (v0.37.0)
Some checks failed
Go / build-and-release (push) Has been cancelled
- Make Blossom view use full available width
- Add "Upload new files" label with Select Files button on right
- Show image/video thumbnails in file list (48x48px)
- Add emoji icons for audio (🎵) and documents (📄)
- Show full hash on screens > 720px, truncated on smaller

Files modified:
- app/web/src/BlossomView.svelte: UI layout and thumbnail changes
- app/web/dist/*: Rebuilt bundle

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 13:07:25 +01:00
0de4137a10 Fix embedded web UI deployment by tracking dist assets (v0.36.23)
Some checks failed
Go / build-and-release (push) Has been cancelled
- Track bundle.js, bundle.css, and all dist assets in git
- Previously only index.html was tracked, breaking VPS deployments
- Remove debug logging from BlossomView

Files modified:
- app/web/dist/*: Add all build assets to git tracking
- app/web/src/BlossomView.svelte: Remove debug code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 12:44:14 +01:00
042acd9ed2 Track all dist assets and remove debug logging 2025-12-25 12:38:54 +01:00
dddf1ac568 Add bundle.js to git tracking for embedded web UI 2025-12-25 12:34:48 +01:00
d6f2a0f7cf Add visible debug bar for role detection 2025-12-25 12:32:40 +01:00
7c60b63df6 Add debug logging for Blossom admin role detection (v0.36.22)
Some checks failed
Go / build-and-release (push) Has been cancelled
- Add console.log to trace currentEffectiveRole value in BlossomView
- Add HTML comment showing role and isAdmin values for debugging

Files modified:
- app/web/src/BlossomView.svelte: Add debug logging for role detection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 12:30:15 +01:00
ab2ac1bf4c Add Blossom admin UI for viewing all users' storage (v0.36.21)
Some checks failed
Go / build-and-release (push) Has been cancelled
- Add ListAllUserStats() storage method to aggregate user blob stats
- Add handleAdminListUsers() handler for admin endpoint
- Add /blossom/admin/users route requiring admin ACL
- Add Admin button to Blossom UI for admin/owner roles
- Add admin view showing all users with file counts and sizes
- Add user detail view to browse individual user's files
- Fetch user profiles (avatar, name) for admin list display

Files modified:
- pkg/blossom/storage.go: Add UserBlobStats struct and ListAllUserStats()
- pkg/blossom/handlers.go: Add handleAdminListUsers() handler
- pkg/blossom/server.go: Add admin/users route
- app/web/src/BlossomView.svelte: Add admin view state, UI, and styles

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 12:04:35 +01:00
96209bd8a5 Fix release deploy to use correct binary path (v0.36.20)
Some checks failed
Go / build-and-release (push) Has been cancelled
- Update deploy command to build to ~/.local/bin/next.orly.dev
- Service uses this path, not ./orly in project directory

Files modified:
- .claude/commands/release.md: Fixed binary output path for VPS deploy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 11:34:20 +01:00
12 changed files with 762 additions and 76 deletions

View File

@@ -50,7 +50,7 @@ If no argument provided, default to `patch`.
11. **Deploy to VPS** by running:
```
ssh 10.0.0.1 'cd ~/src/next.orly.dev && git stash && git pull origin main && export PATH=$PATH:~/go/bin && CGO_ENABLED=0 go build -o orly && sudo systemctl restart orly && ./orly version'
ssh 10.0.0.1 'cd ~/src/next.orly.dev && git stash && git pull origin main && export PATH=$PATH:~/go/bin && CGO_ENABLED=0 go build -o ~/.local/bin/next.orly.dev && sudo systemctl restart orly && ~/.local/bin/next.orly.dev version'
```
12. **Report completion** with the new version and commit hash

87
app/web/dist/bundle.css vendored Normal file

File diff suppressed because one or more lines are too long

24
app/web/dist/bundle.js vendored Normal file

File diff suppressed because one or more lines are too long

1
app/web/dist/bundle.js.map vendored Normal file

File diff suppressed because one or more lines are too long

BIN
app/web/dist/favicon.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

69
app/web/dist/global.css vendored Normal file
View File

@@ -0,0 +1,69 @@
html,
body {
position: relative;
width: 100%;
height: 100%;
}
body {
color: #333;
margin: 0;
padding: 8px;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu,
Cantarell, "Helvetica Neue", sans-serif;
}
a {
color: rgb(0, 100, 200);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
a:visited {
color: rgb(0, 80, 160);
}
label {
display: block;
}
input,
button,
select,
textarea {
font-family: inherit;
font-size: inherit;
-webkit-padding: 0.4em 0;
padding: 0.4em;
margin: 0 0 0.5em 0;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 2px;
}
input:disabled {
color: #ccc;
}
button {
color: #333;
background-color: #f4f4f4;
outline: none;
}
button:disabled {
color: #999;
}
button:not(:disabled):active {
background-color: #ddd;
}
button:focus {
border-color: #666;
}

BIN
app/web/dist/orly.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

View File

@@ -1,5 +1,7 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
import { npubEncode } from "nostr-tools/nip19";
import { fetchUserProfile } from "./nostr.js";
export let isLoggedIn = false;
export let userPubkey = "";
@@ -26,7 +28,15 @@
const MAX_ZOOM = 4;
const ZOOM_STEP = 0.25;
// Admin view state
let isAdminView = false;
let adminUserStats = [];
let isLoadingAdmin = false;
let selectedAdminUser = null;
let selectedUserBlobs = [];
$: canAccess = isLoggedIn && userPubkey;
$: isAdmin = currentEffectiveRole === "admin" || currentEffectiveRole === "owner";
// Track if we've loaded once to prevent repeated loads
let hasLoadedOnce = false;
@@ -149,10 +159,10 @@
function getMimeIcon(mimeType) {
const category = getMimeCategory(mimeType);
switch (category) {
case "image": return "";
case "video": return "";
case "audio": return "";
default: return "";
case "image": return "🖼️";
case "video": return "🎬";
case "audio": return "🎵";
default: return "📄";
}
}
@@ -299,6 +309,121 @@
error = `Failed to upload: ${failed.map(f => f.name).join(", ")}`;
}
}
// Admin functions
function hexToNpub(pubkeyHex) {
try {
return npubEncode(pubkeyHex);
} catch (e) {
return truncateHash(pubkeyHex);
}
}
function truncateNpub(npub) {
if (!npub) return "";
return `${npub.slice(0, 12)}...${npub.slice(-8)}`;
}
async function fetchAdminUserStats() {
isLoadingAdmin = true;
error = "";
try {
const url = `${window.location.origin}/blossom/admin/users`;
const authHeader = await createBlossomAuth(userSigner, "admin");
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) {
throw new Error(`Failed to load user stats: ${response.statusText}`);
}
adminUserStats = await response.json();
// Fetch profiles for each user (non-blocking)
for (const stat of adminUserStats) {
fetchUserProfile(stat.pubkey).then(profile => {
stat.profile = profile || { name: "", picture: "" };
adminUserStats = adminUserStats; // trigger reactivity
}).catch(() => {
stat.profile = { name: "", picture: "" };
});
}
} catch (err) {
console.error("Error fetching admin user stats:", err);
error = err.message || "Failed to load user stats";
} finally {
isLoadingAdmin = false;
}
}
async function loadUserBlobs(pubkeyHex) {
isLoading = true;
error = "";
try {
const url = `${window.location.origin}/blossom/list/${pubkeyHex}`;
const authHeader = await createBlossomAuth(userSigner, "list");
const response = await fetch(url, {
headers: authHeader ? { Authorization: `Nostr ${authHeader}` } : {},
});
if (!response.ok) {
throw new Error(`Failed to load user blobs: ${response.statusText}`);
}
selectedUserBlobs = await response.json();
selectedUserBlobs.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
} catch (err) {
console.error("Error loading user blobs:", err);
error = err.message || "Failed to load user blobs";
} finally {
isLoading = false;
}
}
function enterAdminView() {
isAdminView = true;
fetchAdminUserStats();
}
function exitAdminView() {
isAdminView = false;
adminUserStats = [];
selectedAdminUser = null;
selectedUserBlobs = [];
}
async function selectUser(userStat) {
selectedAdminUser = {
pubkey: userStat.pubkey,
profile: userStat.profile
};
await loadUserBlobs(userStat.pubkey);
}
function exitUserView() {
selectedAdminUser = null;
selectedUserBlobs = [];
}
function handleRefresh() {
if (selectedAdminUser) {
loadUserBlobs(selectedAdminUser.pubkey);
} else if (isAdminView) {
fetchAdminUserStats();
} else {
loadBlobs();
}
}
function getDisplayBlobs() {
if (selectedAdminUser) {
return selectedUserBlobs;
}
return blobs;
}
</script>
<svelte:window on:keydown={handleKeydown} />
@@ -306,13 +431,40 @@
{#if canAccess}
<div class="blossom-view">
<div class="header-section">
{#if selectedAdminUser}
<button class="back-btn" on:click={exitUserView}>
&larr; Back
</button>
<h3 class="user-header">
{#if selectedAdminUser.profile?.picture}
<img src={selectedAdminUser.profile.picture} alt="" class="header-avatar" />
{/if}
{selectedAdminUser.profile?.name || truncateNpub(hexToNpub(selectedAdminUser.pubkey))}
</h3>
{:else if isAdminView}
<button class="back-btn" on:click={exitAdminView}>
&larr; Back
</button>
<h3>All Users Storage</h3>
{:else}
<h3>Blossom Media Storage</h3>
<button class="refresh-btn" on:click={loadBlobs} disabled={isLoading}>
{isLoading ? "Loading..." : "Refresh"}
{/if}
<div class="header-buttons">
{#if isAdmin && !isAdminView && !selectedAdminUser}
<button class="admin-btn" on:click={enterAdminView} disabled={isLoading}>
Admin
</button>
{/if}
<button class="refresh-btn" on:click={handleRefresh} disabled={isLoading || isLoadingAdmin}>
{isLoading || isLoadingAdmin ? "Loading..." : "Refresh"}
</button>
</div>
</div>
{#if !isAdminView && !selectedAdminUser}
<div class="upload-section">
<span class="upload-label">Upload new files</span>
<input
type="file"
multiple
@@ -320,9 +472,6 @@
on:change={handleFileSelect}
class="file-input-hidden"
/>
<button class="select-btn" on:click={triggerFileInput} disabled={isUploading}>
Select Files
</button>
{#if selectedFiles.length > 0}
<span class="selected-count">{selectedFiles.length} file(s) selected</span>
<button
@@ -333,7 +482,11 @@
{isUploading ? uploadProgress : "Upload"}
</button>
{/if}
<button class="select-btn" on:click={triggerFileInput} disabled={isUploading}>
Select Files
</button>
</div>
{/if}
{#if error}
<div class="error-message">
@@ -341,15 +494,59 @@
</div>
{/if}
{#if isLoading && blobs.length === 0}
<div class="loading">Loading blobs...</div>
{:else if blobs.length === 0}
{#if isAdminView && !selectedAdminUser}
<!-- Admin users list view -->
{#if isLoadingAdmin}
<div class="loading">Loading user statistics...</div>
{:else if adminUserStats.length === 0}
<div class="empty-state">
<p>No files found in your Blossom storage.</p>
<p>No users have uploaded files yet.</p>
</div>
{:else}
<div class="admin-users-list">
{#each adminUserStats as userStat}
<div
class="user-stat-item"
on:click={() => selectUser(userStat)}
on:keypress={(e) => e.key === "Enter" && selectUser(userStat)}
role="button"
tabindex="0"
>
<div class="user-avatar-container">
{#if userStat.profile?.picture}
<img src={userStat.profile.picture} alt="" class="user-avatar" />
{:else}
<div class="user-avatar-placeholder"></div>
{/if}
</div>
<div class="user-info">
<div class="user-name">
{userStat.profile?.name || truncateNpub(hexToNpub(userStat.pubkey))}
</div>
<div class="user-npub" title={userStat.pubkey}>
<span class="npub-full">{hexToNpub(userStat.pubkey)}</span>
<span class="npub-truncated">{truncateNpub(hexToNpub(userStat.pubkey))}</span>
</div>
</div>
<div class="user-stats">
<span class="blob-count">{userStat.blob_count} files</span>
<span class="total-size">{formatSize(userStat.total_size_bytes)}</span>
</div>
</div>
{/each}
</div>
{/if}
{:else}
<!-- Normal blob list view (own files or selected user's files) -->
{#if isLoading && getDisplayBlobs().length === 0}
<div class="loading">Loading blobs...</div>
{:else if getDisplayBlobs().length === 0}
<div class="empty-state">
<p>{selectedAdminUser ? "No files found for this user." : "No files found in your Blossom storage."}</p>
</div>
{:else}
<div class="blob-list">
{#each blobs as blob}
{#each getDisplayBlobs() as blob}
<div
class="blob-item"
on:click={() => openModal(blob)}
@@ -357,12 +554,19 @@
role="button"
tabindex="0"
>
<div class="blob-icon">
{getMimeIcon(blob.type)}
<div class="blob-thumbnail">
{#if getMimeCategory(blob.type) === "image"}
<img src={getBlobUrl(blob)} alt="" class="thumbnail-img" />
{:else if getMimeCategory(blob.type) === "video"}
<video src={getBlobUrl(blob)} class="thumbnail-video" muted preload="metadata"></video>
{:else}
<span class="thumbnail-icon">{getMimeIcon(blob.type)}</span>
{/if}
</div>
<div class="blob-info">
<div class="blob-hash" title={blob.sha256}>
{truncateHash(blob.sha256)}
<span class="hash-full">{blob.sha256}</span>
<span class="hash-truncated">{truncateHash(blob.sha256)}</span>
</div>
<div class="blob-meta">
<span class="blob-size">{formatSize(blob.size)}</span>
@@ -383,6 +587,7 @@
{/each}
</div>
{/if}
{/if}
</div>
{:else}
<div class="login-prompt">
@@ -482,7 +687,7 @@
<style>
.blossom-view {
padding: 1em;
max-width: 900px;
box-sizing: border-box;
}
.header-section {
@@ -495,6 +700,60 @@
.header-section h3 {
margin: 0;
color: var(--text-color);
flex: 1;
}
.header-buttons {
display: flex;
align-items: center;
gap: 0.5em;
}
.back-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-color);
padding: 0.5em 1em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
margin-right: 0.5em;
}
.back-btn:hover {
background-color: var(--sidebar-bg);
}
.admin-btn {
background-color: var(--primary);
color: var(--text-color);
border: none;
padding: 0.5em 1em;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.admin-btn:hover:not(:disabled) {
background-color: var(--accent-hover-color);
}
.admin-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.user-header {
display: flex;
align-items: center;
gap: 0.5em;
}
.header-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.refresh-btn {
@@ -527,6 +786,12 @@
flex-wrap: wrap;
}
.upload-label {
color: var(--text-color);
font-size: 0.95em;
flex: 1;
}
.file-input-hidden {
display: none;
}
@@ -594,6 +859,7 @@
display: flex;
flex-direction: column;
gap: 0.5em;
width: 100%;
}
.blob-item {
@@ -611,10 +877,27 @@
background-color: var(--sidebar-bg);
}
.blob-icon {
.blob-thumbnail {
width: 48px;
height: 48px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-color);
border-radius: 4px;
overflow: hidden;
}
.thumbnail-img,
.thumbnail-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-icon {
font-size: 1.5em;
width: 2em;
text-align: center;
}
.blob-info {
@@ -628,6 +911,14 @@
color: var(--text-color);
}
.hash-full {
display: inline;
}
.hash-truncated {
display: none;
}
.blob-meta {
display: flex;
gap: 1em;
@@ -663,6 +954,86 @@
color: var(--text-color);
}
/* Admin users list styles */
.admin-users-list {
display: flex;
flex-direction: column;
gap: 0.5em;
width: 100%;
}
.user-stat-item {
display: flex;
align-items: center;
gap: 1em;
padding: 0.75em 1em;
background-color: var(--card-bg);
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
}
.user-stat-item:hover {
background-color: var(--sidebar-bg);
}
.user-avatar-container {
flex-shrink: 0;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.user-avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--border-color);
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-weight: 500;
color: var(--text-color);
}
.user-npub {
font-family: monospace;
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
}
.npub-full {
display: inline;
}
.npub-truncated {
display: none;
}
.user-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25em;
}
.user-stats .blob-count,
.user-stats .total-size {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
.login-prompt {
text-align: center;
padding: 2em;
@@ -940,6 +1311,24 @@
color: var(--text-color);
}
@media (max-width: 720px) {
.hash-full {
display: none;
}
.hash-truncated {
display: inline;
}
.npub-full {
display: none;
}
.npub-truncated {
display: inline;
}
}
@media (max-width: 600px) {
.blob-item {
flex-wrap: wrap;
@@ -948,7 +1337,7 @@
.blob-date {
width: 100%;
margin-top: 0.5em;
padding-left: 3em;
padding-left: 3.5em;
}
.modal-footer {

View File

@@ -474,6 +474,42 @@ func (s *Server) handleListBlobs(w http.ResponseWriter, r *http.Request) {
}
}
// handleAdminListUsers handles GET /admin/users requests (admin only)
func (s *Server) handleAdminListUsers(w http.ResponseWriter, r *http.Request) {
// Authorization required
authEv, err := ValidateAuthEvent(r, "admin", nil)
if err != nil {
s.setErrorResponse(w, http.StatusUnauthorized, err.Error())
return
}
if authEv == nil {
s.setErrorResponse(w, http.StatusUnauthorized, "authorization required")
return
}
// Check admin ACL
remoteAddr := s.getRemoteAddr(r)
if !s.checkACL(authEv.Pubkey, remoteAddr, "admin") {
s.setErrorResponse(w, http.StatusForbidden, "admin access required")
return
}
// Get all user stats
stats, err := s.storage.ListAllUserStats()
if err != nil {
log.E.F("error listing user stats: %v", err)
s.setErrorResponse(w, http.StatusInternalServerError, "internal server error")
return
}
// Return JSON
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err = json.NewEncoder(w).Encode(stats); err != nil {
log.E.F("error encoding response: %v", err)
}
}
// handleDeleteBlob handles DELETE /<sha256> requests (BUD-02)
func (s *Server) handleDeleteBlob(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")

View File

@@ -108,6 +108,14 @@ func (s *Server) Handler() http.Handler {
s.handleReport(w, r)
return
case path == "admin/users":
if r.Method == http.MethodGet {
s.handleAdminListUsers(w, r)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
case strings.HasPrefix(path, "list/"):
if r.Method == http.MethodGet {
s.handleListBlobs(w, r)

View File

@@ -4,6 +4,8 @@ import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"github.com/dgraph-io/badger/v4"
"lol.mleku.dev/chk"
@@ -453,3 +455,73 @@ func (s *Storage) GetBlobMetadata(sha256Hash []byte) (metadata *BlobMetadata, er
return
}
// UserBlobStats represents storage statistics for a single user
type UserBlobStats struct {
PubkeyHex string `json:"pubkey"`
BlobCount int64 `json:"blob_count"`
TotalSizeBytes int64 `json:"total_size_bytes"`
}
// ListAllUserStats returns storage statistics for all users who have uploaded blobs
func (s *Storage) ListAllUserStats() (stats []*UserBlobStats, err error) {
statsMap := make(map[string]*UserBlobStats)
if err = s.db.View(func(txn *badger.Txn) error {
opts := badger.DefaultIteratorOptions
opts.Prefix = []byte(prefixBlobIndex)
opts.PrefetchValues = false
it := txn.NewIterator(opts)
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
key := string(it.Item().Key())
// Key format: blob:index:<pubkey-hex>:<sha256-hex>
remainder := key[len(prefixBlobIndex):]
parts := strings.SplitN(remainder, ":", 2)
if len(parts) != 2 {
continue
}
pubkeyHex := parts[0]
sha256Hex := parts[1]
// Get or create stats entry
stat, ok := statsMap[pubkeyHex]
if !ok {
stat = &UserBlobStats{PubkeyHex: pubkeyHex}
statsMap[pubkeyHex] = stat
}
stat.BlobCount++
// Get blob size from metadata
metaKey := prefixBlobMeta + sha256Hex
metaItem, errGet := txn.Get([]byte(metaKey))
if errGet != nil {
continue
}
metaItem.Value(func(val []byte) error {
metadata, errDeser := DeserializeBlobMetadata(val)
if errDeser == nil {
stat.TotalSizeBytes += metadata.Size
}
return nil
})
}
return nil
}); chk.E(err) {
return
}
// Convert map to slice
stats = make([]*UserBlobStats, 0, len(statsMap))
for _, stat := range statsMap {
stats = append(stats, stat)
}
// Sort by total size descending
sort.Slice(stats, func(i, j int) bool {
return stats[i].TotalSizeBytes > stats[j].TotalSizeBytes
})
return
}

View File

@@ -1 +1 @@
v0.36.19
v0.37.2