Merge branch 'add-stats-page' into develop

This commit is contained in:
Jesse Plamondon-Willard
2026-06-24 15:49:44 -04:00
15 changed files with 1855 additions and 14 deletions
+2 -1
View File
@@ -31,7 +31,8 @@ _ReSharper*/
# sensitive files
appsettings.Development.json
# Azure generated files
# auto-generated files
src/SMAPI.Web/wwwroot/Content/data
src/SMAPI.Web/Properties/PublishProfiles
src/SMAPI.Web/Properties/ServiceDependencies
+54 -11
View File
@@ -1,11 +1,13 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Hangfire;
using Hangfire.Console;
using Hangfire.Server;
using Humanizer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Toolkit;
@@ -17,6 +19,7 @@ using StardewModdingAPI.Toolkit.Framework.Clients.NexusExport;
using StardewModdingAPI.Web.Framework.Caching;
using StardewModdingAPI.Web.Framework.Caching.CompatibilityRepo;
using StardewModdingAPI.Web.Framework.Caching.CurseForgeExport;
using StardewModdingAPI.Web.Framework.Caching.ModDataset;
using StardewModdingAPI.Web.Framework.Caching.ModDropExport;
using StardewModdingAPI.Web.Framework.Caching.Mods;
using StardewModdingAPI.Web.Framework.Caching.NexusExport;
@@ -64,6 +67,12 @@ internal class BackgroundService : IHostedService, IDisposable
/// <summary>The config settings for mod update checks.</summary>
private static IOptions<ModUpdateCheckConfig>? UpdateCheckConfig;
/// <summary>The mod dataset repository.</summary>
private static IModDatasetRepository? ModDatasetRepo;
/// <summary>The web root path for writing static data files.</summary>
private static string? WebRootPath;
/// <summary>Whether the service has been started.</summary>
[MemberNotNullWhen(true,
nameof(BackgroundService.JobServer),
@@ -75,7 +84,9 @@ internal class BackgroundService : IHostedService, IDisposable
nameof(BackgroundService.ModDropExportCache),
nameof(BackgroundService.NexusExportApiClient),
nameof(BackgroundService.NexusExportCache),
nameof(BackgroundService.UpdateCheckConfig)
nameof(BackgroundService.ModDatasetRepo),
nameof(BackgroundService.UpdateCheckConfig),
nameof(BackgroundService.WebRootPath)
)]
private static bool IsStarted { get; set; }
@@ -90,16 +101,18 @@ internal class BackgroundService : IHostedService, IDisposable
** Hosted service
****/
/// <summary>Construct an instance.</summary>
/// <param name="compatibilityCache">The cache in which to store compatibility list data.</param>
/// <param name="modCache">The cache in which to store mod data.</param>
/// <param name="curseForgeExportCache">The cache in which to store mod data from the CurseForge export API.</param>
/// <param name="curseForgeExportApiClient">The HTTP client for fetching the mod export from the CurseForge export API.</param>
/// /// <param name="modDropExportCache">The cache in which to store mod data from the ModDrop export API.</param>
/// <param name="modDropExportApiClient">The HTTP client for fetching the mod export from the ModDrop export API.</param>
/// <param name="nexusExportCache">The cache in which to store mod data from the Nexus export API.</param>
/// <param name="nexusExportApiClient">The HTTP client for fetching the mod export from the Nexus Mods export API.</param>
/// <param name="compatibilityCache"><inheritdoc cref="CompatibilityCache" path="/summary"/></param>
/// <param name="modCache"><inheritdoc cref="ModCache" path="/summary"/></param>
/// <param name="curseForgeExportCache"><inheritdoc cref="CurseForgeExportCache" path="/summary"/></param>
/// <param name="curseForgeExportApiClient"><inheritdoc cref="CurseForgeExportApiClient" path="/summary"/></param>
/// <param name="modDropExportCache"><inheritdoc cref="ModDropExportCache" path="/summary"/></param>
/// <param name="modDropExportApiClient"><inheritdoc cref="ModDropExportApiClient" path="/summary"/></param>
/// <param name="nexusExportCache"><inheritdoc cref="NexusExportCache" path="/summary"/></param>
/// <param name="nexusExportApiClient"><inheritdoc cref="NexusExportApiClient" path="/summary"/></param>
/// <param name="modDatasetRepo"><inheritdoc cref="ModDatasetRepo" path="/summary"/></param>
/// <param name="hangfireStorage">The Hangfire storage implementation.</param>
/// <param name="updateCheckConfig">The config settings for mod update checks.</param>
/// <param name="updateCheckConfig"><inheritdoc cref="UpdateCheckConfig" path="/summary"/></param>
/// <param name="hostEnvironment">The web host environment metadata.</param>
[SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The Hangfire reference forces it to initialize first, since it's needed by the background service.")]
public BackgroundService(
ICompatibilityCacheRepository compatibilityCache,
@@ -110,8 +123,10 @@ internal class BackgroundService : IHostedService, IDisposable
IModDropExportApiClient modDropExportApiClient,
INexusExportCacheRepository nexusExportCache,
INexusExportApiClient nexusExportApiClient,
IModDatasetRepository modDatasetRepo,
JobStorage hangfireStorage,
IOptions<ModUpdateCheckConfig> updateCheckConfig
IOptions<ModUpdateCheckConfig> updateCheckConfig,
IWebHostEnvironment hostEnvironment
)
{
BackgroundService.CompatibilityCache = compatibilityCache;
@@ -122,7 +137,9 @@ internal class BackgroundService : IHostedService, IDisposable
BackgroundService.ModDropExportCache = modDropExportCache;
BackgroundService.NexusExportCache = nexusExportCache;
BackgroundService.NexusExportApiClient = nexusExportApiClient;
BackgroundService.ModDatasetRepo = modDatasetRepo;
BackgroundService.UpdateCheckConfig = updateCheckConfig;
BackgroundService.WebRootPath = hostEnvironment.WebRootPath;
_ = hangfireStorage; // parameter is only received to initialize it before the background service
}
@@ -146,6 +163,7 @@ internal class BackgroundService : IHostedService, IDisposable
if (enableNexusExport)
BackgroundJob.Enqueue(() => BackgroundService.UpdateNexusExportAsync(null));
BackgroundJob.Enqueue(() => BackgroundService.RemoveStaleModsAsync());
BackgroundJob.Enqueue(() => BackgroundService.UpdateModDatasetAsync(null));
// set recurring tasks
RecurringJob.AddOrUpdate("update compatibility list", () => BackgroundService.UpdateCompatibilityListAsync(null), "*/10 * * * *"); // every 10 minutes
@@ -156,6 +174,7 @@ internal class BackgroundService : IHostedService, IDisposable
if (enableNexusExport)
RecurringJob.AddOrUpdate("update Nexus export", () => BackgroundService.UpdateNexusExportAsync(null), "*/10 * * * *");
RecurringJob.AddOrUpdate("remove stale mods", () => BackgroundService.RemoveStaleModsAsync(), "2/10 * * * *"); // offset by 2 minutes so it runs after updates (e.g. 00:02, 00:12, etc)
RecurringJob.AddOrUpdate("update mod dataset", () => BackgroundService.UpdateModDatasetAsync(null), "0 * * * *"); // hourly
BackgroundService.IsStarted = true;
@@ -242,6 +261,30 @@ internal class BackgroundService : IHostedService, IDisposable
);
}
/// <summary>Clone or pull the Stardew mod dataset repo, then copy the latest data files into the web root for use by frontend scripts.</summary>
/// <param name="context">Information about the context in which the job is performed. This is injected automatically by Hangfire.</param>
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 60, 120])]
public static async Task UpdateModDatasetAsync(PerformContext? context)
{
if (!BackgroundService.IsStarted)
throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks.");
// update repo
context.WriteLine("Updating mod dataset repo...");
await BackgroundService.ModDatasetRepo.UpdateAsync(context.WriteLine);
// copy files
context.WriteLine("Copying data files for script use...");
string copyToPath = Path.Combine(BackgroundService.WebRootPath, "Content", "data", "mods-by-type.jsonl");
Directory.CreateDirectory(Path.GetDirectoryName(copyToPath)!);
File.Copy(BackgroundService.ModDatasetRepo.GetFilePath("stats/mods by type.jsonl"), copyToPath, overwrite: true);
File.Copy(BackgroundService.ModDatasetRepo.GetFilePath("stats/Content Patcher packs by format version.jsonl"), Path.Combine(BackgroundService.WebRootPath, "Content", "data", "content-packs-by-format.jsonl"), overwrite: true);
File.Copy(BackgroundService.ModDatasetRepo.GetFilePath("reference-data/SMAPI costs.jsonl"), Path.Combine(BackgroundService.WebRootPath, "Content", "data", "smapi-costs.jsonl"), overwrite: true);
File.Copy(BackgroundService.ModDatasetRepo.GetFilePath("reference-data/SMAPI DNS queries.json"), Path.Combine(BackgroundService.WebRootPath, "Content", "data", "smapi-dns-queries.json"), overwrite: true);
context.WriteLine("Done!");
}
/// <summary>Remove mods which haven't been requested in over 48 hours.</summary>
public static Task RemoveStaleModsAsync()
{
@@ -77,6 +77,13 @@ internal class IndexController : Controller
return this.View();
}
/// <summary>Display the stats page.</summary>
[HttpGet("/stats")]
public ViewResult Stats()
{
return this.View();
}
/*********
** Private methods
@@ -1,6 +1,5 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -0,0 +1,19 @@
using System;
using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Caching.ModDataset;
/// <summary>Manages the Stardew mod dataset repo.</summary>
internal interface IModDatasetRepository
{
/*********
** Methods
*********/
/// <summary>Fetch the latest mod dataset, if it changed or hasn't been fetched yet.</summary>
/// <param name="log">A callback which should receive progress messages for logging.</param>
Task UpdateAsync(Action<string>? log = null);
/// <summary>Get the full path to a file in the mod dataset.</summary>
/// <param name="relativePath">The file path relative to the <c>dataset</c> folder in the mod dataset repo.</param>
string GetFilePath(string relativePath);
}
@@ -0,0 +1,85 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Caching.ModDataset;
/// <inheritdoc cref="IModDatasetRepository" />
internal class ModDatasetRepository : IModDatasetRepository
{
/*********
** Fields
*********/
/// <summary>The HTTPS URL of the mod dataset Git repo.</summary>
private readonly string RepoUrl;
/// <summary>The full path to the mod dataset repo.</summary>
private readonly string LocalRepoPath;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="repoUrl"><inheritdoc cref="RepoUrl" path="/summary" /></param>
/// <param name="localRepoPath">The path to the mod dataset repo.</param>
public ModDatasetRepository(string repoUrl, string localRepoPath)
{
this.RepoUrl = repoUrl;
this.LocalRepoPath = Path.GetFullPath(
Environment.ExpandEnvironmentVariables(localRepoPath)
);
}
/// <inheritdoc />
public async Task UpdateAsync(Action<string>? log = null)
{
if (!Directory.Exists(Path.Combine(this.LocalRepoPath, ".git")))
{
log?.Invoke("Cloning mod dataset repo...");
Directory.CreateDirectory(Path.GetDirectoryName(this.LocalRepoPath)!);
await this.RunGitAsync("clone", "--depth", "1", this.RepoUrl, this.LocalRepoPath);
}
else
{
log?.Invoke("Fetching latest changes from mod dataset repo...");
await this.RunGitAsync("-C", this.LocalRepoPath, "fetch", "--depth", "1", "origin");
await this.RunGitAsync("-C", this.LocalRepoPath, "reset", "--hard", "FETCH_HEAD");
}
}
/// <inheritdoc />
public string GetFilePath(string relativePath)
{
return Path.Combine(this.LocalRepoPath, "dataset", relativePath);
}
/*********
** Private methods
*********/
/// <summary>Run a Git command and wait for it to complete.</summary>
/// <param name="args">The git arguments.</param>
/// <exception cref="InvalidOperationException">The Git command exited with a non-zero exit code.</exception>
private async Task RunGitAsync(params string[] args)
{
ProcessStartInfo startInfo = new("git", args)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start Git process.");
Task readOutput = process.StandardOutput.ReadToEndAsync();
Task<string> readError = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
await Task.WhenAll(readOutput, readError);
if (process.ExitCode != 0)
throw new InvalidOperationException($"Git command failed with exit code {process.ExitCode}: {await readError}.");
}
}
@@ -0,0 +1,14 @@
namespace StardewModdingAPI.Web.Framework.ConfigModels;
/// <summary>The config settings for the Stardew mod dataset repo.</summary>
internal class ModDatasetConfig
{
/*********
** Accessors
*********/
/// <summary>The HTTPS URL of the mod dataset Git repo.</summary>
public string RepoUrl { get; set; } = null!;
/// <summary>The local path into which to clone the mod dataset repo.</summary>
public string LocalPath { get; set; } = null!;
}
+18 -1
View File
@@ -22,6 +22,7 @@ using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Web.Framework;
using StardewModdingAPI.Web.Framework.Caching.CompatibilityRepo;
using StardewModdingAPI.Web.Framework.Caching.CurseForgeExport;
using StardewModdingAPI.Web.Framework.Caching.ModDataset;
using StardewModdingAPI.Web.Framework.Caching.ModDropExport;
using StardewModdingAPI.Web.Framework.Caching.Mods;
using StardewModdingAPI.Web.Framework.Caching.NexusExport;
@@ -108,6 +109,12 @@ internal class Startup
services.AddSingleton<IModDropExportCacheRepository>(new ModDropExportCacheMemoryRepository());
services.AddSingleton<INexusExportCacheRepository>(new NexusExportCacheMemoryRepository());
// init mod dataset
{
ModDatasetConfig config = this.Configuration.GetRequiredSection("ModDataset").Get<ModDatasetConfig>() ?? throw new InvalidOperationException("Can't initialize server: required 'ModDataset' config section couldn't be loaded.");
services.AddSingleton<IModDatasetRepository>(new ModDatasetRepository(config.RepoUrl, config.LocalPath));
}
// init Hangfire
services
.AddHangfire((_, config) =>
@@ -245,7 +252,17 @@ internal class Startup
.WithOrigins("https://smapi.io")
)
.UseRewriter(this.GetRedirectRules())
.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = this.OnPrepareStaticFileResponse }) // wwwroot folder
.UseStaticFiles(
// serve static files in wwwroot folder
new StaticFileOptions
{
OnPrepareResponse = this.OnPrepareStaticFileResponse,
ContentTypeProvider = new FileExtensionContentTypeProvider
{
Mappings = { [".jsonl"] = "application/jsonl" }
}
}
)
.UseRouting()
.UseAuthorization()
.UseEndpoints(p =>
+148
View File
@@ -0,0 +1,148 @@
@using StardewModdingAPI.Web.Framework.Extensions
@{
ViewData["Title"] = "Mod stats";
}
@section Head {
@* Apache ECharts *@
<script src="@Url.ContentWithCacheBust("~/Content/lib/echarts/dist/echarts.min.js")"></script>
@* jQuery *@
<script src="@Url.ContentWithCacheBust("~/Content/lib/jquery/dist/jquery.min.js")"></script>
@* Vue *@
<script src="@Url.ContentWithCacheBust("~/Content/lib/vue/dist/vue.min.js")"></script>
@* page CSS/JS *@
<link rel="stylesheet" href="@Url.ContentWithCacheBust("~/Content/css/stats.css")" />
<script src="@Url.ContentWithCacheBust("~/Content/js/stats.js")"></script>
<script>
$(function () {
smapi.statsPage({
modsByTypeUri: @this.ForJson(Url.ContentWithCacheBust("~/Content/data/mods-by-type.jsonl")),
contentPacksByFormatUri: @this.ForJson(Url.ContentWithCacheBust("~/Content/data/content-packs-by-format.jsonl")),
smapiCostsUri: @this.ForJson(Url.ContentWithCacheBust("~/Content/data/smapi-costs.jsonl")),
smapiDnsQueriesUri: @this.ForJson(Url.ContentWithCacheBust("~/Content/data/smapi-dns-queries.json"))
});
});
</script>
}
@* quick navigation links *@
@section SidebarExtra {
<nav id="quickNav" hidden>
<h4>Scroll to...</h4>
<ul>
<li><a href="#content">Top</a></li>
<li><a href="#mods-by-type">Mods by type</a></li>
<li><a href="#new-mods">New mods</a></li>
<li><a href="#content-patcher-packs">Content Patcher packs</a></li>
<li><a href="#web-traffic">Web traffic</a></li>
<li><a href="#smapi-costs">SMAPI costs</a></li>
</ul>
</nav>
}
<noscript>
<div class="banner error">
<strong>This page requires JavaScript.</strong><br />
This pages uses JavaScript to render the stats. To view them, please enable JavaScript or <a href="https://github.com/Pathoschild/StardewModData">view the raw data</a>.
</div>
</noscript>
<div v-cloak id="app">
@* data not ready yet *@
<div v-if="isLoadingData" class="loading-indicator"><div></div><div></div><div></div><div></div></div>
<div v-else-if="!anyDataLoaded" class="error">These stats are generated automatically from the <a href="https://github.com/Pathoschild/StardewModData">Stardew mod dataset</a>, which hasn't been fetched yet. Please try again in a few minutes.</div>
@* stats area *@
<div v-else id="stats-area">
@* header *@
<div id="stats-intro">These stats are generated automatically from the <a href="https://github.com/Pathoschild/StardewModData">Stardew mod dataset</a>.</div>
@* mods by type *@
<h2 id="mods-by-type">Mods by type</h2>
<div v-if="modsByType.loadFailed" class="error">These stats couldn't be fetched from the mod dataset.</div>
<template v-else>
<div class="last-updated">Based on data from {{modsByType.lastUpdated}}.</div>
<div class="chart-intro">We have {{modsByType.total.toLocaleString()}} tracked mods. The majority use {{modsByType.topThree[0].label}} ({{modsByType.topThree[0].percent}}%), followed by {{modsByType.topThree[1].label}} at {{modsByType.topThree[1].percent}}% and {{modsByType.topThree[2].label}} at {{modsByType.topThree[2].percent}}%. <a href="https://stardewvalleywiki.com/Modding:Using_XNB_mods">Legacy XNB mods</a> are no longer recommended, and now only make up {{modsByType.xnbPercent}}% of mods.</div>
<div class="chart pie-chart" id="modsByType"></div>
<div class="chart line-chart" id="modsByTypeOverTime"></div>
<div class="chart line-chart" id="modsByTypeOverTimeExcludingOutliers"></div>
</template>
@* new mods *@
<h2 id="new-mods">New mods</h2>
<div v-if="modsByType.loadFailed" class="error">These stats couldn't be fetched from the mod dataset.</div>
<template v-else>
<div class="last-updated">Based on data from {{modsByType.lastUpdated}}.</div>
<div class="chart-intro">
Between {{modsByType.previouslyUpdated}} and {{modsByType.lastUpdated}}, there were {{modsByType.latestNewMods.total.toLocaleString()}} more mods. That includes:
<ul>
<li v-for="entry in modsByType.latestNewMods.byType">{{formatDelta(entry.delta)}} {{entry.label}};</li>
<li>and {{formatDelta(modsByType.latestNewMods.forOther)}} for frameworks with &lt;100 content packs.</li>
</ul>
</div>
<div class="chart pie-chart" id="newModsLastDelta"></div>
<div class="chart bar-chart" id="newMods"></div>
</template>
@* Content Patcher packs *@
<h2 id="content-patcher-packs">Content Patcher packs</h2>
<div v-if="contentPacks.loadFailed" class="error">These stats couldn't be fetched from the mod dataset.</div>
<template v-else>
<div class="last-updated">Based on data from {{contentPacks.lastUpdated}}.</div>
<div class="chart-intro">
We have {{contentPacks.total.toLocaleString()}} tracked <a href="https://www.nexusmods.com/stardewvalley/mods/1915">Content Patcher</a> packs. The top five format versions are (with the change since {{contentPacks.previouslyUpdated}}):
<ul>
<li v-for="(entry, i) in contentPacks.topVersions">{{i == 4 ? "and " : ""}} {{entry.version}} ({{entry.count.toLocaleString()}}, {{formatDelta(entry.delta)}}){{i < 4 ? ";" : "."}}</li>
</ul>
</div>
<div class="chart pie-chart" id="contentPacksByFormat"></div>
<div class="chart line-chart" id="contentPacksByFormatOverTime"></div>
</template>
@* web traffic *@
<h2 id="web-traffic">Web traffic</h2>
<div v-if="dnsQueries.loadFailed" class="error">These stats couldn't be fetched from the mod dataset.</div>
<template v-else>
<div class="last-updated">Based on data from {{dnsQueries.lastUpdated}}.</div>
<div class="chart-intro">SMAPI's web services received about {{(dnsQueries.latestCount / 1000000).toFixed(1)}} million DNS queries in {{dnsQueries.lastUpdated}}. This serves as a rough indicator of player and mod author activity, since SMAPI doesn't collect any telemetry.</div>
<div class="chart" id="dnsQueriesOverTime"></div>
</template>
@* SMAPI costs *@
<h2 id="smapi-costs">SMAPI costs</h2>
<div v-if="costs.loadFailed" class="error">These stats couldn't be fetched from the mod dataset.</div>
<template v-else>
<div class="last-updated">Based on data from {{costs.lastUpdated}}. This information is delayed due to billing cycles.</div>
<div class="chart-intro">
<p>
SMAPI has monthly costs for the domains, update alert servers, web hosting for pages like this, etc. Those monthly costs
<template v-if="costs.percentChange <= 0.05">held steady around US${{costs.latestCost.toFixed(0)}}/month</template>
<template v-else-if="costs.percentChange <= 0.10">{{costs.latestCost > costs.previousCost ? "rose" : "dropped"}} a bit from US${{costs.previousCost.toFixed(0)}} to ${{costs.latestCost.toFixed(0)}}/month</template>
<template v-else>{{costs.latestCost > costs.previousCost ? "rose" : "dropped"}} from US${{costs.previousCost.toFixed(0)}} to ${{costs.latestCost.toFixed(0)}}/month</template>
in {{costs.lastUpdated}}.
</p>
</div>
<div class="chart" id="costsOverTime"></div>
<div id="support-box">
<p>Tip: you can help cover SMAPI costs by joining <a href="https://www.patreon.com/pathoschild">its author's Patreon</a> (starting from $1/month)!</p>
<p>That comes with monthly posts about:</p>
<ul>
<li>what's happening in the Stardew Valley modding community;</li>
<li>analyses of changes to these stats;</li>
<li>sneak peeks at modding changes in upcoming versions of the game;</li>
<li>discussion about proposed modding changes;</li>
<li>and more.</li>
</ul>
</div>
</template>
</div>
</div>
@@ -25,6 +25,7 @@
<li><a href="@Url.PlainAction("Index", "Mods")">Mod compatibility</a></li>
<li><a href="@Url.PlainAction("Index", "LogParser", values: null)">Log parser</a></li>
<li><a href="@Url.PlainAction("Index", "JsonValidator", values: null)">JSON validator</a></li>
<li><a href="@Url.PlainAction("Stats", "Index", values: null)">Mod stats</a></li>
</ul>
@RenderSection("SidebarExtra", required: false)
+5
View File
@@ -70,6 +70,11 @@
"Enabled": true
},
"ModDataset": {
"RepoUrl": "https://github.com/Pathoschild/StardewModData.git",
"LocalPath": "%TEMP%/StardewModDataset"
},
"ModUpdateCheck": {
"SuccessCacheMinutes": 60,
"ErrorCacheMinutes": 5,
+6
View File
@@ -3,6 +3,12 @@
"defaultProvider": "jsdelivr",
"defaultDestination": "wwwroot/Content/lib/[Name]",
"libraries": [
{
"library": "[email protected]",
"files": [
"dist/echarts.min.js"
]
},
{
"library": "[email protected]",
"files": [
@@ -0,0 +1,53 @@
/* let charts cover full available screen */
#content {
max-width: none;
}
#content-column {
right: 0;
}
/* general styles */
div.error {
padding: 2em 0;
color: red;
font-weight: bold;
}
/* stats area */
#stats-area #stats-intro {
max-width: 60em;
margin-top: 2em;
}
#stats-area .last-updated {
font-size: 0.7em;
opacity: 0.7;
}
#stats-area .chart-intro {
max-width: 60em;
margin-top: 1em;
}
#stats-area .chart {
margin: 1em 0;
height: 600px;
border: 1px solid #AAA;
border-radius: 3px;
}
#stats-area .pie-chart {
max-width: 900px; /* pie charts don't extend across page, so keep them left-aligned in their box */
}
#stats-area .bar-chart {
height: 400px; /* bar charts don't need as much vertical room to be readable */
}
#stats-area #support-box {
max-width: 45em;
background: rgba(0, 170, 0, 0.2);
padding: 0.5em 1em;
margin-bottom: 1em;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long