Add file timeseries chart
This commit is contained in:
@@ -39,7 +39,7 @@ let chart_timespans = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
let update_chart = (timespan, interval) => {
|
let update_chart = (timespan, interval) => {
|
||||||
// If the timespan is 0 we calculate the maximum timespan based on the age
|
// If the timespan is 0 we calculate the maximum timespan based on the age
|
||||||
// of the file
|
// of the file
|
||||||
if (timespan === 0) {
|
if (timespan === 0) {
|
||||||
let minutes_since_upload = (new Date().getTime() - Date.parse(file.date_upload)) / 1000 / 60
|
let minutes_since_upload = (new Date().getTime() - Date.parse(file.date_upload)) / 1000 / 60
|
||||||
|
@@ -1,21 +1,140 @@
|
|||||||
<script>
|
<script>
|
||||||
import { formatDataVolume, formatDate } from "../util/Formatting.svelte";
|
import Chart from "../util/Chart.svelte";
|
||||||
|
import { formatDataVolume, formatDate, formatThousands } from "../util/Formatting.svelte";
|
||||||
import Modal from "../util/Modal.svelte";
|
import Modal from "../util/Modal.svelte";
|
||||||
|
import { fs_timeseries } from "./FilesystemAPI";
|
||||||
import { fs_path_url } from "./FilesystemUtil";
|
import { fs_path_url } from "./FilesystemUtil";
|
||||||
|
import { generate_share_url } from "./Sharebar.svelte";
|
||||||
|
import { color_by_name } from "../util/Util.svelte";
|
||||||
|
|
||||||
export let state
|
export let state
|
||||||
export let visible = false
|
export let visible = false
|
||||||
export const toggle = () => {visible = !visible}
|
export const toggle = () => visible = !visible
|
||||||
|
|
||||||
|
$: visibility_change(visible)
|
||||||
|
const visibility_change = (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
update_chart(state.base, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: direct_url = window.location.origin+fs_path_url(state.root.id, state.base.path)
|
||||||
|
$: share_url = generate_share_url(state.path)
|
||||||
|
|
||||||
|
let chart
|
||||||
|
let chart_timespan = 0
|
||||||
|
let chart_interval = 0
|
||||||
|
let chart_timespans = [
|
||||||
|
{label: "Day (1m)", span: 1440, interval: 1},
|
||||||
|
{label: "Week (1h)", span: 10080, interval: 60},
|
||||||
|
{label: "Month (1h)", span: 43200, interval: 60},
|
||||||
|
{label: "Quarter (1d)", span: 131400, interval: 1440},
|
||||||
|
{label: "Year (1d)", span: 525600, interval: 1440},
|
||||||
|
{label: "Two Years (1d)", span: 1051200, interval: 1440},
|
||||||
|
{label: "Five Years (1d)", span: 2628000, interval: 1440},
|
||||||
|
]
|
||||||
|
|
||||||
|
let total_downloads = 0
|
||||||
|
let total_bandwidth_free = 0
|
||||||
|
let total_bandwidth_paid = 0
|
||||||
|
|
||||||
|
$: update_chart(state.base, chart_timespan, chart_interval)
|
||||||
|
let update_chart = async (base, timespan, interval) => {
|
||||||
|
if (!visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the timespan is 0 we calculate the maximum timespan based on the age
|
||||||
|
// of the file
|
||||||
|
if (timespan === 0) {
|
||||||
|
let minutes_since_upload = (new Date().getTime() - Date.parse(base.created)) / 1000 / 60
|
||||||
|
|
||||||
|
console.log("minutes", minutes_since_upload)
|
||||||
|
|
||||||
|
for (let i = 0; i < chart_timespans.length; i++) {
|
||||||
|
timespan = chart_timespans[i].span
|
||||||
|
interval = chart_timespans[i].interval
|
||||||
|
|
||||||
|
if (chart_timespans[i].span > minutes_since_upload) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chart_timespan = timespan
|
||||||
|
chart_interval = interval
|
||||||
|
|
||||||
|
console.log("Updating graph", chart_timespan, chart_interval)
|
||||||
|
|
||||||
|
let start = new Date()
|
||||||
|
start.setMinutes(start.getMinutes() - timespan)
|
||||||
|
let end = new Date()
|
||||||
|
|
||||||
|
try {
|
||||||
|
let resp = await fs_timeseries(state.root.id, base.path, start, end, interval)
|
||||||
|
|
||||||
|
chart.data().datasets = [
|
||||||
|
{
|
||||||
|
label: "Unique downloads",
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderColor: color_by_name("chart_1_color"),
|
||||||
|
backgroundColor: color_by_name("chart_1_color"),
|
||||||
|
}, {
|
||||||
|
label: "Free downloads",
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderColor: color_by_name("chart_2_color"),
|
||||||
|
backgroundColor: color_by_name("chart_2_color"),
|
||||||
|
}, {
|
||||||
|
label: "Paid downloads",
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
borderColor: color_by_name("chart_3_color"),
|
||||||
|
backgroundColor: color_by_name("chart_3_color"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
resp.downloads.timestamps.forEach((val, idx) => {
|
||||||
|
let date = new Date(val);
|
||||||
|
let str = ("00" + (date.getMonth() + 1)).slice(-2);
|
||||||
|
str += "-" + ("00" + date.getDate()).slice(-2);
|
||||||
|
str += " " + ("00" + date.getHours()).slice(-2);
|
||||||
|
str += ":" + ("00" + date.getMinutes()).slice(-2);
|
||||||
|
resp.downloads.timestamps[idx] = " " + str + " "; // Poor man's padding
|
||||||
|
});
|
||||||
|
|
||||||
|
total_downloads = 0
|
||||||
|
total_bandwidth_free = 0
|
||||||
|
total_bandwidth_paid = 0
|
||||||
|
|
||||||
|
resp.downloads.amounts.forEach(val => total_downloads += val);
|
||||||
|
resp.bandwidth_free.amounts.forEach((val, idx) => {
|
||||||
|
resp.bandwidth_free.amounts[idx] = val / base.file_size;
|
||||||
|
total_bandwidth_free += val
|
||||||
|
});
|
||||||
|
resp.bandwidth_paid.amounts.forEach((val, idx) => {
|
||||||
|
resp.bandwidth_paid.amounts[idx] = val / base.file_size;
|
||||||
|
total_bandwidth_paid += val
|
||||||
|
});
|
||||||
|
chart.data().labels = resp.downloads.timestamps
|
||||||
|
chart.data().datasets[0].data = resp.downloads.amounts
|
||||||
|
chart.data().datasets[1].data = resp.bandwidth_free.amounts
|
||||||
|
chart.data().datasets[2].data = resp.bandwidth_paid.amounts
|
||||||
|
chart.update()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to get time series data:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal bind:visible={visible} title="Details" width="800px">
|
<Modal bind:visible={visible} title="Details" width="1000px">
|
||||||
<table style="min-width: 100%;">
|
<h3 class="indent">Node details</h3>
|
||||||
<tr><td colspan="2"><h3>Node details</h3></td></tr>
|
<table style="width: 100%;">
|
||||||
<tr><td>Name</td><td>{state.base.name}</td></tr>
|
<tr><td>Name</td><td>{state.base.name}</td></tr>
|
||||||
<tr><td>Path</td><td>{state.base.path}</td></tr>
|
<tr><td>Path</td><td>{state.base.path}</td></tr>
|
||||||
<tr><td>Type</td><td>{state.base.type}</td></tr>
|
<tr><td>Date created</td><td>{formatDate(state.base.created, true, true, true)}</td></tr>
|
||||||
<tr><td>Date created</td><td>{formatDate(state.base.date_created, true, true, true)}</td></tr>
|
<tr><td>Date modified</td><td>{formatDate(state.base.modified, true, true, true)}</td></tr>
|
||||||
<tr><td>Date modified</td><td>{formatDate(state.base.date_modified, true, true, true)}</td></tr>
|
|
||||||
<tr><td>Mode</td><td>{state.base.mode_string}</td></tr>
|
<tr><td>Mode</td><td>{state.base.mode_string}</td></tr>
|
||||||
{#if state.base.id}
|
{#if state.base.id}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -24,27 +143,60 @@ export const toggle = () => {visible = !visible}
|
|||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
{#if state.base.type === "file"}
|
{#if state.base.type === "file"}
|
||||||
<tr><td>File type</td><td>{state.base.file_type}</td></tr>
|
<tr><td>File type</td><td>{state.base.file_type}</td></tr>
|
||||||
<tr><td>File size</td><td>{formatDataVolume(state.base.file_size)}</td></tr>
|
<tr><td>File size</td><td>{formatDataVolume(state.base.file_size)}</td></tr>
|
||||||
<tr><td>SHA256 sum</td><td>{state.base.sha256_sum}</td></tr>
|
<tr><td>Unique downloads</td><td>{formatThousands(total_downloads)}</td></tr>
|
||||||
|
<tr>
|
||||||
|
<td>Free bandwidth used</td>
|
||||||
|
<td>
|
||||||
|
{formatDataVolume(total_bandwidth_free, 4)}
|
||||||
|
( {formatThousands(total_bandwidth_free)} B ),
|
||||||
|
{(total_bandwidth_free/state.base.file_size).toFixed(1)}x file size
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Premium bandwidth used</td>
|
||||||
|
<td>
|
||||||
|
{formatDataVolume(total_bandwidth_paid, 4)}
|
||||||
|
( {formatThousands(total_bandwidth_paid)} B ),
|
||||||
|
{(total_bandwidth_paid/state.base.file_size).toFixed(1)}x file size
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td>SHA256 sum</td><td>{state.base.sha256_sum}</td></tr>
|
||||||
|
{/if}
|
||||||
|
<tr><td>Direct URL</td><td><a href="{direct_url}">{direct_url}</a></td></tr>
|
||||||
|
{#if share_url !== ""}
|
||||||
|
<tr><td>Share URL</td><td><a href="{share_url}">{share_url}</a></td></tr>
|
||||||
{/if}
|
{/if}
|
||||||
<tr>
|
|
||||||
<td>Stat</td>
|
|
||||||
<td>
|
|
||||||
<a href="{fs_path_url(state.root.id, state.base.path)}?stat">Open stat API</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Direct link</td>
|
|
||||||
<td>
|
|
||||||
<a href="{fs_path_url(state.root.id, state.base.path)}">Open direct link</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr><td colspan="2"><h3>Bucket details</h3></td></tr>
|
|
||||||
<tr><td>ID</td><td>{state.root.id}</td></tr>
|
|
||||||
<tr><td>Name</td><td>{state.root.name}</td></tr>
|
|
||||||
<tr><td>Date created</td><td>{formatDate(state.root.date_created, true, true, true)}</td></tr>
|
|
||||||
<tr><td>Date modified</td><td>{formatDate(state.root.date_modified, true, true, true)}</td></tr>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<h3 class="indent">Download statistics</h3>
|
||||||
|
|
||||||
|
<div class="button_bar">
|
||||||
|
{#each chart_timespans as ts}
|
||||||
|
<button
|
||||||
|
on:click={() => { update_chart(state.base, ts.span, ts.interval) }}
|
||||||
|
class:button_highlight={chart_timespan == ts.span}>
|
||||||
|
{ts.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Chart bind:this={chart} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
td:first-child {
|
||||||
|
white-space: nowrap;
|
||||||
|
word-break: keep-all;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button_bar {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@@ -50,7 +50,7 @@ const update_base = async base => {
|
|||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (socket === null) {
|
if (socket === null) {
|
||||||
state_update()
|
update_base(base)
|
||||||
}
|
}
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
@@ -94,3 +94,17 @@ export const fs_search = async (bucket, path, term, limit = 10) => {
|
|||||||
}
|
}
|
||||||
return resp.json()
|
return resp.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const fs_timeseries = async (bucket, path, start, end, interval = 60) => {
|
||||||
|
const resp = await fetch(
|
||||||
|
fs_path_url(bucket, path) +
|
||||||
|
"?timeseries" +
|
||||||
|
"&start=" + start.toISOString() +
|
||||||
|
"&end=" + end.toISOString() +
|
||||||
|
"&interval=" + interval
|
||||||
|
)
|
||||||
|
if (resp.status >= 400) {
|
||||||
|
throw await resp.text()
|
||||||
|
}
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
@@ -4,7 +4,12 @@ export const fs_split_path = (path) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const fs_path_url = (bucket, path) => {
|
export const fs_path_url = (bucket, path) => {
|
||||||
return window.api_endpoint + "/filesystem/" + bucket + encodeURIComponent(path)
|
// Encode all path elements separately to preserve forward slashes
|
||||||
|
let split = path.split("/")
|
||||||
|
for (let i = 0; i < split.length; i++) {
|
||||||
|
split[i] = encodeURIComponent(split[i])
|
||||||
|
}
|
||||||
|
return api_endpoint + "/filesystem/" + bucket + split.join("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fs_file_url = (bucket, path) => {
|
export const fs_file_url = (bucket, path) => {
|
||||||
|
@@ -64,7 +64,7 @@ const recursive_size = (file) => {
|
|||||||
<IconBlock icon_href={fs_node_icon(state.root.id, state.base, 256, 256)}>
|
<IconBlock icon_href={fs_node_icon(state.root.id, state.base, 256, 256)}>
|
||||||
Compressed size: {formatDataVolume(state.base.file_size, 3)}<br/>
|
Compressed size: {formatDataVolume(state.base.file_size, 3)}<br/>
|
||||||
Uncompressed size: {formatDataVolume(uncomp_size, 3)} (Ratio: {comp_ratio.toFixed(2)}x)<br/>
|
Uncompressed size: {formatDataVolume(uncomp_size, 3)} (Ratio: {comp_ratio.toFixed(2)}x)<br/>
|
||||||
Uploaded on: {formatDate(state.base.date_created, true, true, true)}
|
Uploaded on: {formatDate(state.base.created, true, true, true)}
|
||||||
<br/>
|
<br/>
|
||||||
<button class="button_highlight" on:click={() => {dispatch("download")}}>
|
<button class="button_highlight" on:click={() => {dispatch("download")}}>
|
||||||
<i class="icon">download</i>
|
<i class="icon">download</i>
|
||||||
|
Reference in New Issue
Block a user