Add admin file details page for debugging file entries

This commit is contained in:
2026-09-10 00:32:49 +02:00
parent 050b5bbf99
commit 4eb0d9bedd
12 changed files with 302 additions and 69 deletions

View File

@@ -28,7 +28,6 @@
"@rollup/plugin-typescript": "^11.1.6", "@rollup/plugin-typescript": "^11.1.6",
"@types/jsmediatags": "^3.9.6", "@types/jsmediatags": "^3.9.6",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"baseline-browser-mapping": "^2.10.13",
"rollup": "^4.24.4", "rollup": "^4.24.4",
"rollup-plugin-livereload": "^2.0.5", "rollup-plugin-livereload": "^2.0.5",
"rollup-plugin-svelte": "^7.2.2", "rollup-plugin-svelte": "^7.2.2",
@@ -2708,9 +2707,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.13", "version": "2.11.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz",
"integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -2804,9 +2803,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001749", "version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001749.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
"integrity": "sha512-0rw2fJOmLfnzCRbkm8EyHL8SvI2Apu5UbnQuTsJ0ClgrH8hcwFooJ1s5R0EP8o8aVrFu8++ae29Kt9/gZAZp/Q==", "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
"devOptional": true, "devOptional": true,
"funding": [ "funding": [
{ {

View File

@@ -18,7 +18,6 @@
"@rollup/plugin-typescript": "^11.1.6", "@rollup/plugin-typescript": "^11.1.6",
"@types/jsmediatags": "^3.9.6", "@types/jsmediatags": "^3.9.6",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"baseline-browser-mapping": "^2.10.13",
"rollup": "^4.24.4", "rollup": "^4.24.4",
"rollup-plugin-livereload": "^2.0.5", "rollup-plugin-livereload": "^2.0.5",
"rollup-plugin-svelte": "^7.2.2", "rollup-plugin-svelte": "^7.2.2",

View File

@@ -0,0 +1,180 @@
<script lang="ts">
import { onMount } from "svelte"
import { formatDataVolume, formatDate, formatNumber } from "util/Formatting"
import { get_endpoint, check_response } from "lib/NovaAPI"
import { get_status, type Peer } from "lib/AdminAPI"
let hash = $state("")
let file = $state(null)
let error = $state("")
let loading = $state(false)
const load = async () => {
const h = hash.trim().replace(/^0x/i, "")
file = null
error = ""
loading = true
try {
file = await check_response(
await fetch(get_endpoint() + "/admin/file/" + h + "/info")
)
} catch (err) {
error = err.message || JSON.stringify(err)
} finally {
loading = false
}
}
// Server UUID to peer, so shard locations can be shown as hostnames. A
// decommissioned server is still in the shard map but no longer in the peer
// list, so the UUID has to remain the fallback
let peers: { [id: string]: Peer } = $state({})
onMount(async () => {
const status = await get_status()
peers = Object.fromEntries(status.peers.map(p => [p.id, p]))
})
const server = (id: string) => {
const peer = peers[id]
return peer ? peer.hostname : id
}
// Go marshals a missing timestamp as the zero time instead of null
const date = (d: string) => (!d || d.startsWith("0001-")) ? "never" : formatDate(d)
</script>
<section>
<h2>File inspector</h2>
<form onsubmit={e => { e.preventDefault(); load() }}>
<label for="hash_input">File hash (hex sha256)</label>
<input id="hash_input" bind:value={hash} onclick={e => e.currentTarget.select()}/>
<button type="submit" disabled={loading}>
<i class="icon">search</i> Look up
</button>
</form>
{#if error}
<p class="highlight_red">{error}</p>
{/if}
</section>
{#if file}
<section>
<h3>File row</h3>
<div class="table_scroll">
<table>
<tbody>
<tr><td>Hash</td><td>{file.hash}</td></tr>
<tr><td>Hash UUID</td><td>{file.hash_uuid}</td></tr>
<tr><td>SHA1</td><td>{file.hash_sha1}</td></tr>
<tr><td>MD5</td><td>{file.hash_md5}</td></tr>
<tr><td>Size</td><td>{formatDataVolume(file.size, 4)} ({file.size} B)</td></tr>
<tr><td>Created</td><td>{date(file.created)}</td></tr>
<tr><td>Thumbnail</td><td>{file.thumbnail_status || "none"}</td></tr>
<tr>
<td>Abuse</td>
<td>
{#if file.abuse_type}
{file.abuse_type}, reported by {file.abuse_reporter_name}
&lt;{file.abuse_reporter_email}&gt;
{:else}
none
{/if}
</td>
</tr>
<tr><td>Last referenced</td><td>{date(file.last_referenced)}</td></tr>
<tr><td>Last dereferenced</td><td>{date(file.last_dereferenced)}</td></tr>
<tr>
<td>Collectable after</td>
<td>
{date(file.collectable_after)}
{#if file.live_references > 0}
(blocked by {file.live_references} live references)
{/if}
</td>
</tr>
<tr>
<td>Cache servers</td>
<td>{file.cache_servers?.map(server).join(", ") || "none"}</td>
</tr>
<tr>
<td>Reed-Solomon</td>
<td>
{file.data_shards} data + {file.parity_shards} parity,
{formatDataVolume(file.shard_size, 4)} per shard,
{formatDataVolume(file.shard_block_size, 4)} blocks
</td>
</tr>
<tr>
<td>Redundancy</td>
<td>
{formatNumber(file.redundancy, 3)}x
{#if file.missing_shards?.length}
<span class="highlight_red">
missing shards: {file.missing_shards.join(", ")}
</span>
{/if}
</td>
</tr>
</tbody>
</table>
</div>
<h3>Storage locations ({file.shards.length} shards)</h3>
<div class="table_scroll">
<table>
<thead>
<tr><td>Index</td><td>Shard hash</td><td>Server</td></tr>
</thead>
<tbody>
{#each file.shards as shard}
<tr>
<td>{shard.index}</td>
<td>{shard.hash}</td>
<td title={shard.server_id}>{server(shard.server_id)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<h3>
References ({file.references.length}{file.reference_limit ? "+" : ""},
{file.live_references} live)
</h3>
<div class="table_scroll">
<table>
<thead>
<tr>
<td>ID</td>
<td>Name</td>
<td>User</td>
<td>Size</td>
<td>Type</td>
<td>Views</td>
<td>Created</td>
<td>Modified</td>
<td>Deleted</td>
<td>Abuse</td>
</tr>
</thead>
<tbody>
{#each file.references as ref}
<tr>
<td><a href="/d/{ref.id}">{ref.id}</a></td>
<td>{ref.name}</td>
<td>{ref.user_id}</td>
<td>{formatDataVolume(ref.size, 3)}</td>
<td>{ref.type}</td>
<td>{ref.views}</td>
<td>{date(ref.created)}</td>
<td>{date(ref.modified)}</td>
<td>{ref.deleted ? formatDate(ref.deleted) : ""}</td>
<td>{ref.abuse_type}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}

View File

@@ -6,6 +6,7 @@ import { color_by_name } from "util/Util";
import ServerDiagnostics from "./ServerDiagnostics.svelte"; import ServerDiagnostics from "./ServerDiagnostics.svelte";
import PeerTable from "./PeerTable.svelte"; import PeerTable from "./PeerTable.svelte";
import { get_endpoint } from "lib/NovaAPI"; import { get_endpoint } from "lib/NovaAPI";
import { get_status, type ServerStatus } from "lib/AdminAPI";
let graphEgress: Chart = $state() let graphEgress: Chart = $state()
let graphDownloads: Chart = $state() let graphDownloads: Chart = $state()
@@ -60,34 +61,32 @@ const loadGraph = (minutes: number, interval: number, live: boolean) => {
// Load performance statistics // Load performance statistics
let lastOrder: string = $state(); let lastOrder: string = $state();
let status = $state({ let status: ServerStatus = $state({
running_since: "",
pid: 0, pid: 0,
cpu_profile_running_since: "", cpu_profile_running_since: "",
db_latency: 0, db_latency: 0,
db_time: "", db_time: "",
query_statistics: [],
filesystem_watcher_threads: 0,
filesystem_watcher_listeners: 0,
peers: [],
cache_threshold: 0, cache_threshold: 0,
local_read_size: 0,
local_read_size_per_sec: 0,
local_reads: 0, local_reads: 0,
local_reads_per_sec: 0, local_reads_per_sec: 0,
local_read_size: 0,
local_read_size_per_sec: 0,
local_readers: 0, local_readers: 0,
neighbour_read_size: 0,
neighbour_read_size_per_sec: 0,
neighbour_reads: 0, neighbour_reads: 0,
neighbour_reads_per_sec: 0, neighbour_reads_per_sec: 0,
neighbour_read_size: 0,
neighbour_read_size_per_sec: 0,
neighbour_readers: 0, neighbour_readers: 0,
remote_read_size: 0,
remote_read_size_per_sec: 0,
remote_reads: 0, remote_reads: 0,
remote_reads_per_sec: 0, remote_reads_per_sec: 0,
remote_read_size: 0,
remote_read_size_per_sec: 0,
remote_readers: 0, remote_readers: 0,
peers: [],
query_statistics: [],
running_since: "",
stats_watcher_listeners: 0,
stats_watcher_threads: 0,
filesystem_watcher_listeners: 0,
filesystem_watcher_threads: 0,
}) })
let total_reads = $derived(status.local_reads + status.neighbour_reads + status.remote_reads) let total_reads = $derived(status.local_reads + status.neighbour_reads + status.remote_reads)
let total_read_size = $derived(status.local_read_size + status.neighbour_read_size + status.remote_read_size) let total_read_size = $derived(status.local_read_size + status.neighbour_read_size + status.remote_read_size)
@@ -95,9 +94,7 @@ let total_read_size = $derived(status.local_read_size + status.neighbour_read_si
function getStats(order: string) { function getStats(order: string) {
lastOrder = order lastOrder = order
fetch(get_endpoint() + "/status").then( get_status().then(resp => {
resp => resp.json()
).then(resp => {
// Sort all queries by the selected sort column // Sort all queries by the selected sort column
resp.query_statistics.sort((a, b) => { resp.query_statistics.sort((a, b) => {
if (typeof (a[order]) === "number") { if (typeof (a[order]) === "number") {

View File

@@ -100,6 +100,14 @@ const groups: {
{metric: "pixelstore_reed_solomon_read_size", data_type: "bytes"}, {metric: "pixelstore_reed_solomon_read_size", data_type: "bytes"},
{metric: "pixelstore_read_retry_success", data_type: "number"}, {metric: "pixelstore_read_retry_success", data_type: "number"},
{metric: "pixelstore_read_retry_error", data_type: "number"}, {metric: "pixelstore_read_retry_error", data_type: "number"},
{metric: "pixelstore_remote_read", data_type: "number"},
{metric: "pixelstore_remote_read_ttfb", data_type: "duration"},
{
metric: "pixelstore_remote_read_ttfb_avg",
agg_base: "pixelstore_remote_read_ttfb",
agg_divisor: "pixelstore_remote_read",
data_type: "duration",
},
], ],
}, { }, {
title: "Pixelstore shards", expanded: false, title: "Pixelstore shards", expanded: false,

View File

@@ -1,4 +1,5 @@
import { loading_finish, loading_start } from "lib/Loading"; import { get_status } from "lib/AdminAPI";
import { loading_run } from "lib/Loading";
import { check_response, get_endpoint } from "lib/NovaAPI"; import { check_response, get_endpoint } from "lib/NovaAPI";
import hsl2rgb from "pure-color/convert/hsl2rgb"; import hsl2rgb from "pure-color/convert/hsl2rgb";
import rgb2hex from "pure-color/convert/rgb2hex"; import rgb2hex from "pure-color/convert/rgb2hex";
@@ -61,15 +62,10 @@ export const host_label = async (id: string): Promise<string> => {
} }
export const load_host_names = async () => { export const load_host_names = async () => {
loading_start() await loading_run(async () => {
try { const status = await get_status()
const req = await fetch(get_endpoint() + "/status") for (const peer of status.peers) {
const j = await req.json()
for (const peer of j.peers) {
host_names[peer.id] = peer.hostname host_names[peer.id] = peer.hostname
} }
} finally { })
loading_finish()
}
} }

View File

@@ -3,37 +3,17 @@ import { flip } from "svelte/animate";
import { formatDataVolume } from "util/Formatting"; import { formatDataVolume } from "util/Formatting";
import SortButton from "layout/SortButton.svelte"; import SortButton from "layout/SortButton.svelte";
import { loading_run } from "lib/Loading"; import { loading_run } from "lib/Loading";
import { admin_decommission_node } from "lib/AdminAPI"; import { admin_decommission_node, type Peer } from "lib/AdminAPI";
let { // The computed columns are not part of the API response, they are filled in
peers = $bindable([]) // by the effect below
}: { type TablePeer = Peer & {
peers: {
id: string
ip: string
port: number
hostname: string
role: string
reachable: boolean
unreachable_count: number
latency: number
last_seen: string
free_space: number
min_free_space: number
load_1_min: number
load_5_min: number
load_15_min: number
avg_network_tx: number
avg_network_rx: number
port_speed: number
cache_threshold: number
// Our props
avg_network_total?: number avg_network_total?: number
usage_percent?: number usage_percent?: number
network_ratio?: number network_ratio?: number
}[] }
} = $props();
let { peers = $bindable([]) }: { peers: TablePeer[] } = $props();
// If not all peers are online we show the decommission button // If not all peers are online we show the decommission button
let all_reachable = $derived(peers.reduce((acc, val) => { let all_reachable = $derived(peers.reduce((acc, val) => {

View File

@@ -11,6 +11,7 @@ import PayPalTaxes from "./PayPalTaxes.svelte";
import UserBans from "./user_bans/UserBans.svelte"; import UserBans from "./user_bans/UserBans.svelte";
import InvoiceVat from "./InvoiceVAT.svelte"; import InvoiceVat from "./InvoiceVAT.svelte";
import HostMetrics from "./HostMetrics.svelte"; import HostMetrics from "./HostMetrics.svelte";
import FileInfo from "./FileInfo.svelte";
let pages = [ let pages = [
{ {
@@ -53,6 +54,11 @@ let pages = [
title: "Host Metrics", title: "Host Metrics",
icon: "area_chart", icon: "area_chart",
component: HostMetrics, component: HostMetrics,
}, {
path: "/admin/file_info",
title: "File Inspector",
icon: "search",
component: FileInfo,
}, { }, {
path: "/admin/mollie_settlements", path: "/admin/mollie_settlements",
title: "Prepaid accounting", title: "Prepaid accounting",

View File

@@ -39,3 +39,66 @@ export const admin_decommission_node = async (peer_id: string) => {
) )
) as GenericResponse ) as GenericResponse
}; };
export type Peer = {
id: string
ip: string
port: number
hostname: string
role: string
reachable: boolean
unreachable_count: number
latency: number
last_seen: string
free_space: number
min_free_space: number
load_1_min: number
load_5_min: number
load_15_min: number
avg_network_tx: number
avg_network_rx: number
port_speed: number
cache_threshold: number
}
export type QueryStats = {
query_name: string
calls: number
total_duration: number
average_duration: number
callers: { name: string, count: number }[]
}
export type ServerStatus = {
running_since: string
pid: number
cpu_profile_running_since: string
db_latency: number
db_time: string
query_statistics: QueryStats[]
filesystem_watcher_threads: number
filesystem_watcher_listeners: number
peers: Peer[]
cache_threshold: number
local_reads: number
local_reads_per_sec: number
local_read_size: number
local_read_size_per_sec: number
local_readers: number
neighbour_reads: number
neighbour_reads_per_sec: number
neighbour_read_size: number
neighbour_read_size_per_sec: number
neighbour_readers: number
remote_reads: number
remote_reads_per_sec: number
remote_read_size: number
remote_read_size_per_sec: number
remote_readers: number
}
export const get_status = async () => {
return await check_response(
await fetch(get_endpoint() + "/status")
) as ServerStatus
};

View File

@@ -72,8 +72,13 @@ export const formatDuration = (ms: number, decimals: number) => {
res += Math.floor((ms % hour) / minute) + "m " res += Math.floor((ms % hour) / minute) + "m "
remainingDecimals-- remainingDecimals--
} }
if (ms >= second && remainingDecimals > 0) {
res += Math.floor((ms % minute) / second) + "s "
remainingDecimals--
}
if (remainingDecimals > 0) { if (remainingDecimals > 0) {
res += ((ms % minute) / second).toFixed(remainingDecimals) + "s" res += (ms % second) + "ms "
remainingDecimals--
} }
return res return res
} }

View File

@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": [ "lib": [
"es2017", "es2019",
"dom" "dom"
], ],
"paths": { "paths": {
@@ -10,7 +10,7 @@
] ]
}, },
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"target": "ES2017", "target": "ES2019",
"noUnusedLocals": true, "noUnusedLocals": true,
"module": "preserve", "module": "preserve",
"moduleResolution": "bundler", "moduleResolution": "bundler",

View File

@@ -548,6 +548,7 @@ footer, .footer_content,
.upload_task, .upload_task,
.add_button, .add_button,
.expandable, .expandable,
.bubble,
.upload_widget { .upload_widget {
border: none !important; border: none !important;
border-radius: 0 !important; border-radius: 0 !important;
@@ -596,7 +597,6 @@ code {
background-color: #fff !important; background-color: #fff !important;
} }
.file_preview,
hr, hr,
.toolbar > .separator, .toolbar > .separator,
.toolbar > .grid > .separator, .toolbar > .grid > .separator,
@@ -676,6 +676,7 @@ footer, .footer_content,
.upload_task, .upload_task,
.add_button, .add_button,
.expandable, .expandable,
.bubble,
.upload_widget, .upload_widget,
.highlight_shaded { .highlight_shaded {
border: none !important; border: none !important;
@@ -721,7 +722,6 @@ code {
border-radius: 0 !important; border-radius: 0 !important;
} }
.file_preview,
hr, hr,
.toolbar > .separator, .toolbar > .separator,
.toolbar > .grid > .separator { .toolbar > .grid > .separator {