2026-08-06 12:58:54 -05:00
|
|
|
using SafelyYou;
|
|
|
|
|
using SafelyYou.Api;
|
2026-08-06 16:24:04 -05:00
|
|
|
using SafelyYou.Data;
|
2026-08-06 15:54:07 -05:00
|
|
|
using Serilog;
|
2026-08-06 12:58:54 -05:00
|
|
|
|
2026-08-06 11:33:04 -05:00
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
|
|
|
|
|
|
// Add services to the container.
|
|
|
|
|
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
2026-08-06 15:54:07 -05:00
|
|
|
|
|
|
|
|
Log.Logger = new LoggerConfiguration()
|
|
|
|
|
.WriteTo.Console()
|
|
|
|
|
.CreateLogger();
|
|
|
|
|
|
|
|
|
|
builder.Services.AddSerilog();
|
2026-08-06 11:33:04 -05:00
|
|
|
builder.Services.AddOpenApi();
|
|
|
|
|
|
2026-08-06 12:58:54 -05:00
|
|
|
builder.Services.AddSingleton<KnownDevices>(_ =>
|
|
|
|
|
{
|
2026-08-06 15:54:07 -05:00
|
|
|
var deviceIds = File.ReadAllLines("./devices.csv").Skip(1).Select(id => new DeviceId(id)).ToHashSet();
|
|
|
|
|
Log.Logger.Information("@deviceIds", deviceIds);
|
2026-08-06 12:58:54 -05:00
|
|
|
return new KnownDevices(deviceIds);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
builder.Services.AddSingleton<HeartBeatHistories>();
|
2026-08-06 13:17:29 -05:00
|
|
|
builder.Services.AddSingleton<UploadTimes>();
|
2026-08-06 12:58:54 -05:00
|
|
|
|
2026-08-06 11:33:04 -05:00
|
|
|
var app = builder.Build();
|
2026-08-06 15:54:07 -05:00
|
|
|
app.UseSerilogRequestLogging();
|
2026-08-06 11:33:04 -05:00
|
|
|
|
|
|
|
|
// Configure the HTTP request pipeline.
|
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
|
|
|
{
|
|
|
|
|
app.MapOpenApi();
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 15:54:07 -05:00
|
|
|
//app.UseHttpsRedirection();
|
2026-08-06 11:33:04 -05:00
|
|
|
|
2026-08-06 12:58:54 -05:00
|
|
|
// heartbeat history
|
|
|
|
|
// upload stats
|
|
|
|
|
|
2026-08-06 15:54:07 -05:00
|
|
|
app.MapPost("api/v1/devices/{deviceId}/heartbeat", (string deviceId, HeartbeatRequest request,
|
2026-08-06 12:58:54 -05:00
|
|
|
KnownDevices knownDevices, HeartBeatHistories histories) =>
|
|
|
|
|
{
|
|
|
|
|
var device = new DeviceId(deviceId);
|
|
|
|
|
if (!knownDevices.Contains(device))
|
|
|
|
|
{
|
|
|
|
|
return Results.NotFound(new NotFoundResponse($"Device ID {deviceId} not found"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var addResult = histories.Add(device, request.SentAt);
|
|
|
|
|
|
|
|
|
|
return addResult switch
|
|
|
|
|
{
|
2026-08-06 13:17:29 -05:00
|
|
|
DeviceHeartBeatHistory.AddResult.Added => Results.NoContent(),
|
2026-08-06 12:58:54 -05:00
|
|
|
DeviceHeartBeatHistory.AddResult.OutOfSequence => Results.InternalServerError(
|
|
|
|
|
new ErrorResponse("Attempted to add a timestamp that was out of sequence or invalid.")),
|
|
|
|
|
_ => throw new ArgumentOutOfRangeException()
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-06 15:54:07 -05:00
|
|
|
app.MapPost("api/v1/devices/{deviceId}/stats", (string deviceId, UploadStatsRequest request, KnownDevices knownDevices, UploadTimes uploadTimes) =>
|
2026-08-06 11:33:04 -05:00
|
|
|
{
|
2026-08-06 13:17:29 -05:00
|
|
|
var device = new DeviceId(deviceId);
|
|
|
|
|
if (!knownDevices.Contains(device))
|
|
|
|
|
{
|
|
|
|
|
return Results.NotFound(new NotFoundResponse($"Device ID {deviceId} not found"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uploadTimes.Add(device, request.UploadTime);
|
|
|
|
|
|
|
|
|
|
return Results.NoContent();
|
|
|
|
|
|
|
|
|
|
// not sure where the 500 would go here beyond doing a try/catch
|
2026-08-06 15:54:07 -05:00
|
|
|
// note: sent_at isn't used here since the data is a short-cut of aggregation, so it's dropped.
|
2026-08-06 13:40:07 -05:00
|
|
|
});
|
2026-08-06 13:17:29 -05:00
|
|
|
|
2026-08-06 15:54:07 -05:00
|
|
|
app.MapGet("api/v1/devices/{deviceId}/stats", (string deviceId, KnownDevices knownDevices, HeartBeatHistories histories, UploadTimes uploadTimes) =>
|
2026-08-06 13:40:07 -05:00
|
|
|
{
|
|
|
|
|
var device = new DeviceId(deviceId);
|
|
|
|
|
if (!knownDevices.Contains(device))
|
|
|
|
|
{
|
|
|
|
|
return Results.NotFound(new NotFoundResponse($"Device ID {deviceId} not found"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var averageUploadTimeNanos = uploadTimes.AverageUploadTimeNanoseconds(device);
|
2026-08-06 15:54:07 -05:00
|
|
|
var timeSpanText = FormatAsGoDuration(averageUploadTimeNanos);
|
2026-08-06 13:40:07 -05:00
|
|
|
var uptime = histories.Uptime(device);
|
|
|
|
|
|
|
|
|
|
return Results.Ok(new GetDeviceStatsResponse(timeSpanText, uptime));
|
|
|
|
|
|
|
|
|
|
// no 500 here
|
|
|
|
|
});
|
2026-08-06 11:33:04 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
app.Run();
|
|
|
|
|
|
2026-08-06 15:54:07 -05:00
|
|
|
// Matches Go's time.Duration.String() (e.g. "3m17.331667813s").
|
|
|
|
|
static string FormatAsGoDuration(System.Numerics.BigInteger nanos)
|
|
|
|
|
{
|
|
|
|
|
if (nanos <= 0) return "0s";
|
|
|
|
|
|
|
|
|
|
var u = (ulong)nanos;
|
|
|
|
|
Span<char> buf = stackalloc char[32];
|
|
|
|
|
var w = buf.Length;
|
|
|
|
|
|
|
|
|
|
// Fractional seconds from nanoseconds, omitting trailing zeros — same as Go fmtFrac(..., 9).
|
|
|
|
|
var frac = u % 1_000_000_000UL;
|
|
|
|
|
u /= 1_000_000_000UL;
|
|
|
|
|
|
|
|
|
|
buf[--w] = 's';
|
|
|
|
|
if (frac != 0)
|
|
|
|
|
{
|
|
|
|
|
var print = false;
|
|
|
|
|
for (var i = 0; i < 9; i++)
|
|
|
|
|
{
|
|
|
|
|
var digit = frac % 10;
|
|
|
|
|
frac /= 10;
|
|
|
|
|
if (digit != 0) print = true;
|
|
|
|
|
if (print) buf[--w] = (char)('0' + digit);
|
|
|
|
|
}
|
|
|
|
|
buf[--w] = '.';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var secs = u % 60;
|
|
|
|
|
u /= 60;
|
|
|
|
|
do
|
|
|
|
|
{
|
|
|
|
|
buf[--w] = (char)('0' + secs % 10);
|
|
|
|
|
secs /= 10;
|
|
|
|
|
} while (secs > 0);
|
|
|
|
|
|
|
|
|
|
if (u > 0)
|
|
|
|
|
{
|
|
|
|
|
buf[--w] = 'm';
|
|
|
|
|
var mins = u % 60;
|
|
|
|
|
u /= 60;
|
|
|
|
|
do
|
|
|
|
|
{
|
|
|
|
|
buf[--w] = (char)('0' + mins % 10);
|
|
|
|
|
mins /= 10;
|
|
|
|
|
} while (mins > 0);
|
|
|
|
|
|
|
|
|
|
if (u > 0)
|
|
|
|
|
{
|
|
|
|
|
buf[--w] = 'h';
|
|
|
|
|
do
|
|
|
|
|
{
|
|
|
|
|
buf[--w] = (char)('0' + u % 10);
|
|
|
|
|
u /= 10;
|
|
|
|
|
} while (u > 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new string(buf[w..]);
|
|
|
|
|
}
|
|
|
|
|
|