mirror of
https://github.com/Pathoschild/SMAPI.git
synced 2026-10-12 02:08:51 +00:00
Merge branch 'add-stats-page' into develop
This commit is contained in:
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 <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)
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
"Enabled": true
|
||||
},
|
||||
|
||||
"ModDataset": {
|
||||
"RepoUrl": "https://github.com/Pathoschild/StardewModData.git",
|
||||
"LocalPath": "%TEMP%/StardewModDataset"
|
||||
},
|
||||
|
||||
"ModUpdateCheck": {
|
||||
"SuccessCacheMinutes": 60,
|
||||
"ErrorCacheMinutes": 5,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user