Initial: JSONL→SQLite Exporter + Cost Analytics Web UI
This commit is contained in:
commit
9803f8c727
14 files changed
+1449
No files matched your search
@@ -0,0 +1,96 @@
|
||||
using Dapper;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var dbPath = builder.Configuration["DbPath"]
|
||||
?? Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "sessions.db");
|
||||
|
||||
builder.Services.AddSingleton<Func<SqliteConnection>>(() =>
|
||||
new SqliteConnection($"Data Source={Path.GetFullPath(dbPath)}"));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
// GET /api/cost-buckets?from=&to=&bucketMinutes=
|
||||
// Returns N buckets of aggregated cost
|
||||
app.MapGet("/api/cost-buckets", (Func<SqliteConnection> factory, string from, string to, int bucketMinutes) =>
|
||||
{
|
||||
using var db = factory();
|
||||
var rows = db.Query(@"
|
||||
SELECT
|
||||
CAST((strftime('%s', timestamp) - strftime('%s', @from)) / (@bucketSec) AS INTEGER) AS bucket,
|
||||
SUM(CAST(json_extract(data, '$.message.usage.cost.total') AS REAL)) AS cost,
|
||||
COUNT(*) AS count,
|
||||
MIN(timestamp) AS bucketStart,
|
||||
MAX(timestamp) AS bucketEnd
|
||||
FROM entries
|
||||
WHERE json_extract(data, '$.message.usage.cost.total') IS NOT NULL
|
||||
AND timestamp >= @from AND timestamp < @to
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC",
|
||||
new { from, to, bucketSec = bucketMinutes * 60 });
|
||||
|
||||
return Results.Ok(rows);
|
||||
});
|
||||
|
||||
// GET /api/cost-entries?from=&to=
|
||||
// Returns all cost entries in a time range
|
||||
app.MapGet("/api/cost-entries", (Func<SqliteConnection> factory, string from, string to) =>
|
||||
{
|
||||
using var db = factory();
|
||||
var rows = db.Query(@"
|
||||
SELECT id, session, type, timestamp,
|
||||
json_extract(data, '$.message.usage.cost.total') AS cost,
|
||||
json_extract(data, '$.message.usage.input') AS inputTokens,
|
||||
json_extract(data, '$.message.usage.output') AS outputTokens,
|
||||
json_extract(data, '$.message.model') AS model,
|
||||
data
|
||||
FROM entries
|
||||
WHERE json_extract(data, '$.message.usage.cost.total') IS NOT NULL
|
||||
AND timestamp >= @from AND timestamp < @to
|
||||
ORDER BY timestamp ASC",
|
||||
new { from, to });
|
||||
|
||||
return Results.Ok(rows);
|
||||
});
|
||||
|
||||
// GET /api/cost-summary?from=&to=
|
||||
app.MapGet("/api/cost-summary", (Func<SqliteConnection> factory, string? from, string? to) =>
|
||||
{
|
||||
using var db = factory();
|
||||
var conditions = new List<string> { "json_extract(data, '$.message.usage.cost.total') IS NOT NULL" };
|
||||
if (from != null) conditions.Add("timestamp >= @from");
|
||||
if (to != null) conditions.Add("timestamp < @to");
|
||||
var where = string.Join(" AND ", conditions);
|
||||
|
||||
var row = db.QuerySingle($@"
|
||||
SELECT COALESCE(SUM(CAST(json_extract(data, '$.message.usage.cost.total') AS REAL)), 0) AS totalCost,
|
||||
COUNT(*) AS totalEntries
|
||||
FROM entries
|
||||
WHERE {where}",
|
||||
new { from, to });
|
||||
|
||||
return Results.Ok(row);
|
||||
});
|
||||
|
||||
// POST /api/sync - run export.mjs to import new entries
|
||||
app.MapPost("/api/sync", async () =>
|
||||
{
|
||||
var scriptPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "export.mjs"));
|
||||
var psi = new System.Diagnostics.ProcessStartInfo("node", scriptPath)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(scriptPath)!
|
||||
};
|
||||
var proc = System.Diagnostics.Process.Start(psi)!;
|
||||
var stdout = await proc.StandardOutput.ReadToEndAsync();
|
||||
var stderr = await proc.StandardError.ReadToEndAsync();
|
||||
await proc.WaitForExitAsync();
|
||||
return Results.Ok(new { exitCode = proc.ExitCode, output = (stdout + stderr).Trim() });
|
||||
});
|
||||
|
||||
app.Run();
|
||||
Reference in new issue
Block a user