✨ Usage drive
This commit is contained in:
@@ -1,6 +1,25 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace DysonNetwork.Drive.Billing;
|
namespace DysonNetwork.Drive.Billing;
|
||||||
|
|
||||||
public class UsageController
|
[ApiController]
|
||||||
|
[Route("api/billing/usage")]
|
||||||
|
public class UsageController(UsageService usageService) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[HttpGet]
|
||||||
}
|
public async Task<ActionResult<TotalUsageDetails>> GetTotalUsage()
|
||||||
|
{
|
||||||
|
return await usageService.GetTotalUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{poolId:guid}")]
|
||||||
|
public async Task<ActionResult<UsageDetails>> GetPoolUsage(Guid poolId)
|
||||||
|
{
|
||||||
|
var usageDetails = await usageService.GetPoolUsage(poolId);
|
||||||
|
if (usageDetails == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
return usageDetails;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,6 +1,99 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace DysonNetwork.Drive.Billing;
|
namespace DysonNetwork.Drive.Billing;
|
||||||
|
|
||||||
public class UsageService
|
public class UsageDetails
|
||||||
{
|
{
|
||||||
|
public required Guid PoolId { get; set; }
|
||||||
|
public required string PoolName { get; set; }
|
||||||
|
public required long UsageBytes { get; set; }
|
||||||
|
public required double Cost { get; set; }
|
||||||
|
public required long FileCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UsageDetailsWithPercentage : UsageDetails
|
||||||
|
{
|
||||||
|
public required double Percentage { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TotalUsageDetails
|
||||||
|
{
|
||||||
|
public required List<UsageDetailsWithPercentage> PoolUsages { get; set; }
|
||||||
|
public required long TotalUsageBytes { get; set; }
|
||||||
|
public required double TotalCost { get; set; }
|
||||||
|
public required long TotalFileCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UsageService(AppDatabase db)
|
||||||
|
{
|
||||||
|
public async Task<TotalUsageDetails> GetTotalUsage()
|
||||||
|
{
|
||||||
|
var poolUsages = await db.Pools
|
||||||
|
.Select(p => new UsageDetails
|
||||||
|
{
|
||||||
|
PoolId = p.Id,
|
||||||
|
PoolName = p.Name,
|
||||||
|
UsageBytes = db.Files
|
||||||
|
.Where(f => f.PoolId == p.Id)
|
||||||
|
.Sum(f => f.Size),
|
||||||
|
Cost = db.Files
|
||||||
|
.Where(f => f.PoolId == p.Id)
|
||||||
|
.Sum(f => f.Size) / 1024.0 / 1024.0 *
|
||||||
|
(p.BillingConfig.CostMultiplier ?? 1.0),
|
||||||
|
FileCount = db.Files
|
||||||
|
.Count(f => f.PoolId == p.Id)
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var totalUsage = poolUsages.Sum(p => p.UsageBytes);
|
||||||
|
var totalCost = poolUsages.Sum(p => p.Cost);
|
||||||
|
var totalFileCount = poolUsages.Sum(p => p.FileCount);
|
||||||
|
|
||||||
|
var poolUsagesWithPercentage = poolUsages.Select(p => new UsageDetailsWithPercentage
|
||||||
|
{
|
||||||
|
PoolId = p.PoolId,
|
||||||
|
PoolName = p.PoolName,
|
||||||
|
UsageBytes = p.UsageBytes,
|
||||||
|
Cost = p.Cost,
|
||||||
|
FileCount = p.FileCount,
|
||||||
|
Percentage = totalUsage > 0 ? (double)p.UsageBytes / totalUsage : 0
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new TotalUsageDetails
|
||||||
|
{
|
||||||
|
PoolUsages = poolUsagesWithPercentage,
|
||||||
|
TotalUsageBytes = totalUsage,
|
||||||
|
TotalCost = totalCost,
|
||||||
|
TotalFileCount = totalFileCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UsageDetails?> GetPoolUsage(Guid poolId)
|
||||||
|
{
|
||||||
|
var pool = await db.Pools.FindAsync(poolId);
|
||||||
|
if (pool == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var usageBytes = await db.Files
|
||||||
|
.Where(f => f.PoolId == poolId)
|
||||||
|
.SumAsync(f => f.Size);
|
||||||
|
|
||||||
|
var fileCount = await db.Files
|
||||||
|
.Where(f => f.PoolId == poolId)
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
|
var cost = usageBytes / 1024.0 / 1024.0 *
|
||||||
|
(pool.BillingConfig.CostMultiplier ?? 1.0);
|
||||||
|
|
||||||
|
return new UsageDetails
|
||||||
|
{
|
||||||
|
PoolId = pool.Id,
|
||||||
|
PoolName = pool.Name,
|
||||||
|
UsageBytes = usageBytes,
|
||||||
|
Cost = cost,
|
||||||
|
FileCount = fileCount
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
@@ -11,10 +11,12 @@
|
|||||||
"@vueuse/core": "^13.5.0",
|
"@vueuse/core": "^13.5.0",
|
||||||
"aspnet-prerendering": "^3.0.1",
|
"aspnet-prerendering": "^3.0.1",
|
||||||
"cfturnstile-vue3": "^2.0.0",
|
"cfturnstile-vue3": "^2.0.0",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tus-js-client": "^4.3.1",
|
"tus-js-client": "^4.3.1",
|
||||||
"vue": "^3.5.17",
|
"vue": "^3.5.17",
|
||||||
|
"vue-chartjs": "^5.3.2",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -162,6 +164,8 @@
|
|||||||
|
|
||||||
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
|
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
|
||||||
|
|
||||||
|
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||||
|
|
||||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||||
|
|
||||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||||
@@ -400,6 +404,8 @@
|
|||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
|
"chart.js": ["chart.js@4.5.0", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ=="],
|
||||||
|
|
||||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
@@ -854,6 +860,8 @@
|
|||||||
|
|
||||||
"vue": ["vue@3.5.17", "", { "dependencies": { "@vue/compiler-dom": "3.5.17", "@vue/compiler-sfc": "3.5.17", "@vue/runtime-dom": "3.5.17", "@vue/server-renderer": "3.5.17", "@vue/shared": "3.5.17" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g=="],
|
"vue": ["vue@3.5.17", "", { "dependencies": { "@vue/compiler-dom": "3.5.17", "@vue/compiler-sfc": "3.5.17", "@vue/runtime-dom": "3.5.17", "@vue/server-renderer": "3.5.17", "@vue/shared": "3.5.17" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-LbHV3xPN9BeljML+Xctq4lbz2lVHCR6DtbpTf5XIO6gugpXUN49j2QQPcMj086r9+AkJ0FfUT8xjulKKBkkr9g=="],
|
||||||
|
|
||||||
|
"vue-chartjs": ["vue-chartjs@5.3.2", "", { "peerDependencies": { "chart.js": "^4.1.1", "vue": "^3.0.0-0 || ^2.7.0" } }, "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw=="],
|
||||||
|
|
||||||
"vue-eslint-parser": ["vue-eslint-parser@10.2.0", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw=="],
|
"vue-eslint-parser": ["vue-eslint-parser@10.2.0", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw=="],
|
||||||
|
|
||||||
"vue-router": ["vue-router@4.5.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="],
|
"vue-router": ["vue-router@4.5.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.2.0" } }, "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw=="],
|
||||||
|
@@ -22,10 +22,12 @@
|
|||||||
"@vueuse/core": "^13.5.0",
|
"@vueuse/core": "^13.5.0",
|
||||||
"aspnet-prerendering": "^3.0.1",
|
"aspnet-prerendering": "^3.0.1",
|
||||||
"cfturnstile-vue3": "^2.0.0",
|
"cfturnstile-vue3": "^2.0.0",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tus-js-client": "^4.3.1",
|
"tus-js-client": "^4.3.1",
|
||||||
"vue": "^3.5.17",
|
"vue": "^3.5.17",
|
||||||
|
"vue-chartjs": "^5.3.2",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@@ -1,9 +1,103 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="h-full"></section>
|
<section class="h-full container-fluid mx-auto py-4 px-5">
|
||||||
|
<div class="h-full flex justify-center items-center" v-if="!usage">
|
||||||
|
<n-spin />
|
||||||
|
</div>
|
||||||
|
<n-grid cols="1 s:2 m:3 l:4" responsive="screen" :x-gap="16" :y-gap="16" v-else>
|
||||||
|
<n-gi>
|
||||||
|
<n-card class="h-stats">
|
||||||
|
<n-statistic label="All Uploads" tabular-nums>
|
||||||
|
<n-number-animation
|
||||||
|
:from="0"
|
||||||
|
:to="toGigabytes(usage.total_usage_bytes)"
|
||||||
|
:precision="3"
|
||||||
|
/>
|
||||||
|
<template #suffix>GiB</template>
|
||||||
|
</n-statistic>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-card class="h-stats">
|
||||||
|
<n-statistic label="All Files" tabular-nums>
|
||||||
|
<n-number-animation :from="0" :to="usage.total_file_count" />
|
||||||
|
</n-statistic>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-card class="h-stats">
|
||||||
|
<n-statistic label="Cost" tabular-nums>
|
||||||
|
<n-number-animation :from="0" :to="usage.total_cost" :precision="2" />
|
||||||
|
<template #suffix>NSD</template>
|
||||||
|
</n-statistic>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi>
|
||||||
|
<n-card class="h-stats">
|
||||||
|
<n-statistic label="Pools" tabular-nums>
|
||||||
|
<n-number-animation :from="0" :to="usage.pool_usages.length" />
|
||||||
|
</n-statistic>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
<n-gi span="2">
|
||||||
|
<n-card class="ratio-video" title="Pool Usage">
|
||||||
|
<pie
|
||||||
|
:data="chartData"
|
||||||
|
:options="{
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { position: isDesktop ? 'right' : 'bottom' } },
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</n-card>
|
||||||
|
</n-gi>
|
||||||
|
</n-grid>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserStore } from '@/stores/user';
|
import { NSpin, NCard, NStatistic, NGrid, NGi, NNumberAnimation } from 'naive-ui'
|
||||||
|
import { Chart as ChartJS, Title, Tooltip, Legend, ArcElement } from 'chart.js'
|
||||||
|
import { Pie } from 'vue-chartjs'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
ChartJS.register(Title, Tooltip, Legend, ArcElement)
|
||||||
|
|
||||||
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
|
const isDesktop = breakpoints.greaterOrEqual('md')
|
||||||
|
|
||||||
|
const chartData = computed(() => ({
|
||||||
|
labels: usage.value.pool_usages.map((pool: any) => pool.pool_name),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Pool Usage',
|
||||||
|
backgroundColor: '#7D80BAFF',
|
||||||
|
data: usage.value.pool_usages.map((pool: any) => pool.usage_bytes),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
const usage = ref<any>()
|
||||||
|
async function fetchUsage() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/billing/usage')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Network response was not ok')
|
||||||
|
}
|
||||||
|
usage.value = await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch usage data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => fetchUsage())
|
||||||
|
|
||||||
|
function toGigabytes(bytes: number): number {
|
||||||
|
return bytes / (1024 * 1024 * 1024)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.h-stats {
|
||||||
|
height: 105px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@@ -139,6 +139,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<Storage.FileService>();
|
services.AddScoped<Storage.FileService>();
|
||||||
services.AddScoped<Storage.FileReferenceService>();
|
services.AddScoped<Storage.FileReferenceService>();
|
||||||
services.AddScoped<Billing.UsageService>();
|
services.AddScoped<Billing.UsageService>();
|
||||||
|
services.AddScoped<Billing.UsageService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
@@ -21,7 +21,7 @@ public class RemoteStorageConfig
|
|||||||
|
|
||||||
public class BillingConfig
|
public class BillingConfig
|
||||||
{
|
{
|
||||||
public double CostMultiplier { get; set; } = 1.0;
|
public double? CostMultiplier { get; set; } = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PolicyConfig
|
public class PolicyConfig
|
||||||
|
Reference in New Issue
Block a user