Previews and stats

This commit is contained in:
Kieran 2022-01-28 00:18:27 +00:00
parent 666181abcc
commit be4bebc97f
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
13 changed files with 222 additions and 39 deletions

View File

@ -14,6 +14,7 @@ public class DownloadController : Controller
_storage = storage;
}
[ResponseCache(Location = ResponseCacheLocation.Any, Duration = 86400)]
[HttpGet]
[Route("{id}")]
public async Task DownloadFile([FromRoute] string id)

View File

@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Mvc;
using VoidCat.Model;
using VoidCat.Services;
namespace VoidCat.Controllers
{
[Route("stats")]
public class StatsController : Controller
{
private readonly IStatsCollector _statsCollector;
public StatsController(IStatsCollector statsCollector)
{
_statsCollector = statsCollector;
}
[HttpGet]
public async Task<GlobalStats> GetGlobalStats()
{
var bw = await _statsCollector.GetBandwidth();
return new(bw);
}
[HttpGet]
[Route("{id}")]
public async Task<FileStats> GetFileStats([FromRoute] string id)
{
var bw = await _statsCollector.GetBandwidth(id.FromBase58Guid());
return new(bw);
}
}
public sealed record GlobalStats(Bandwidth Bandwidth);
public sealed record FileStats(Bandwidth Bandwidth);
}

View File

@ -6,6 +6,9 @@ var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
services.AddRouting();
services.AddControllers().AddNewtonsoftJson();
services.AddMemoryCache();
services.AddScoped<IFileStorage, LocalDiskFileIngressFactory>();
services.AddScoped<IStatsCollector, InMemoryStatsCollector>();

View File

@ -4,5 +4,10 @@
{
ValueTask TrackIngress(Guid id, ulong amount);
ValueTask TrackEgress(Guid id, ulong amount);
ValueTask<Bandwidth> GetBandwidth();
ValueTask<Bandwidth> GetBandwidth(Guid id);
}
public sealed record Bandwidth(ulong Ingress, ulong Egress);
}

View File

@ -1,36 +1,52 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Memory;
namespace VoidCat.Services
{
public class InMemoryStatsCollector : IStatsCollector
{
private readonly ConcurrentDictionary<Guid, ulong> _ingress = new();
private readonly ConcurrentDictionary<Guid, ulong> _egress = new();
private static Guid _global = new Guid("{A98DFDCC-C4E1-4D42-B818-912086FC6157}");
private readonly IMemoryCache _cache;
public InMemoryStatsCollector(IMemoryCache cache)
{
_cache = cache;
}
public ValueTask TrackIngress(Guid id, ulong amount)
{
if (_ingress.ContainsKey(id) && _ingress.TryGetValue(id, out var v))
{
_ingress.TryUpdate(id, v + amount, v);
}
else
{
_ingress.TryAdd(id, amount);
}
Incr(IngressKey(id), amount);
Incr(IngressKey(_global), amount);
return ValueTask.CompletedTask;
}
public ValueTask TrackEgress(Guid id, ulong amount)
{
if (_egress.ContainsKey(id) && _egress.TryGetValue(id, out var v))
{
_egress.TryUpdate(id, v + amount, v);
}
else
{
_egress.TryAdd(id, amount);
}
Incr(EgressKey(id), amount);
Incr(EgressKey(_global), amount);
return ValueTask.CompletedTask;
}
public ValueTask<Bandwidth> GetBandwidth()
=> ValueTask.FromResult(GetBandwidthInternal(_global));
public ValueTask<Bandwidth> GetBandwidth(Guid id)
=> ValueTask.FromResult(GetBandwidthInternal(id));
private Bandwidth GetBandwidthInternal(Guid id)
{
var i = _cache.Get(IngressKey(id)) as ulong?;
var o = _cache.Get(EgressKey(id)) as ulong?;
return new(i ?? 0UL, o ?? 0UL);
}
private void Incr(string k, ulong amount)
{
ulong v;
_cache.TryGetValue(k, out v);
_cache.Set(k, v + amount);
}
private string IngressKey(Guid id) => $"stats:ingress:{id}";
private string EgressKey(Guid id) => $"stats:egress:{id}";
}
}

View File

@ -1,10 +1,23 @@
import { Fragment } from 'react';
import { FilePreview } from "./FilePreview";
import { Dropzone } from "./Dropzone";
import { GlobalStats } from "./GlobalStats";
import './App.css';
import {FilePreview} from "./FilePreview";
import {Uploader} from "./Uploader";
function App() {
let hasPath = window.location.pathname !== "/";
return hasPath ? <FilePreview id={window.location.pathname.substr(1)}/> : <Uploader/>;
return (
<div className="app">
{hasPath ? <FilePreview id={window.location.pathname.substr(1)} />
: (
<Fragment>
<Dropzone />
<GlobalStats />
</Fragment>
)}
</div>
);
}
export default App;

View File

@ -1,7 +1,7 @@
import {Fragment, useState} from "react";
import {FileUpload} from "./FileUpload";
export function Uploader(props) {
export function Dropzone(props) {
let [files, setFiles] = useState([]);
function selectFiles(e) {
@ -34,9 +34,5 @@ export function Uploader(props) {
);
}
return (
<div className="app">
{files.length === 0 ? renderDrop() : renderUploads()}
</div>
);
return files.length === 0 ? renderDrop() : renderUploads();
}

View File

@ -1,3 +1,16 @@
.preview {
text-align: center;
}
margin-top: 2vh;
}
.preview > a {
margin-bottom: 1vh;
}
.preview img {
width: 100%;
}
.preview video {
width: 100%;
}

View File

@ -1,23 +1,50 @@
import {useEffect, useState} from "react";
import { Fragment, useEffect, useState } from "react";
import "./FilePreview.css";
export function FilePreview(props) {
let [info, setInfo] = useState();
async function loadInfo() {
let req = await fetch(`/upload/${props.id}`);
if(req.ok) {
if (req.ok) {
let info = await req.json();
setInfo(info);
}
}
function renderTypes() {
let link = `/d/${info.id}`;
if (info.metadata) {
switch (info.metadata.mimeType) {
case "image/jpg":
case "image/jpeg":
case "image/png": {
return <img src={link} alt={info.metadata.name} />;
}
case "video/mp4":
case "video/matroksa":
case "video/x-matroska": {
return <video src={link} controls />;
}
}
}
return null;
}
useEffect(() => {
loadInfo();
}, []);
return (
<div className={"preview"}>
{info ? <a href={`/d/${info.id}`}>{info.metadata?.name ?? info.id}</a> : "Not Found"}
</div>
<div className="preview">
{info ? (
<Fragment>
this.Download(
<a className="btn" href={`/d/${info.id}`}>{info.metadata?.name ?? info.id}</a>)
{renderTypes()}
</Fragment>
) : "Not Found"}
</div>
);
}

View File

@ -23,6 +23,28 @@ export function FileUpload(props) {
setResult(rsp);
}
}
async function updateMetadata(result) {
let metaReq = {
editSecret: result.editSecret,
metadata: {
name: props.file.name,
mimeType: props.file.type
}
};
let req = await fetch(`/upload/${result.id}`, {
method: "PATCH",
body: JSON.stringify(metaReq),
headers: {
"content-type": "application/json"
}
});
if (req.ok) {
// nothing
}
}
function renderStatus() {
if(result) {
@ -43,11 +65,18 @@ export function FileUpload(props) {
);
}
}
useEffect(() => {
console.log(props.file);
doUpload();
}, []);
useEffect(() => {
if (result) {
updateMetadata(result);
}
}, [result]);
return (
<div className="upload">
<div className="info">

View File

@ -0,0 +1,5 @@
.stats {
display: grid;
grid-auto-flow: column;
margin: 0 100px;
}

View File

@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
import { FormatBytes } from "./Util";
import "./GlobalStats.css";
export function GlobalStats(props) {
let [stats, setStats] = useState();
async function loadStats() {
let req = await fetch("/stats");
if (req.ok) {
setStats(await req.json());
}
}
useEffect(() => loadStats(), []);
return (
<div className="stats">
<div>Ingress:</div>
<div>{FormatBytes(stats?.bandwidth?.ingress ?? 0)}</div>
<div>Egress:</div>
<div>{FormatBytes(stats?.bandwidth?.egress ?? 0)}</div>
</div>
);
}

View File

@ -16,4 +16,18 @@ a {
a:hover {
text-decoration: underline;
}
.btn {
display: inline-block;
line-height: 1.3;
font-size: large;
font-weight: bold;
text-transform: uppercase;
border-radius: 20px;
background-color: white;
color: black;
padding: 10px 30px;
user-select: none;
cursor: pointer;
}