version-0.1 #1

Merged
wamplerj merged 18 commits from version-0.1 into main 2026-07-01 13:30:08 -07:00
25 changed files with 1828 additions and 3 deletions
Showing only changes of commit 5179f5479c - Show all commits

View File

@@ -12,10 +12,12 @@
"@iconify-json/ri": "^1.2.0",
"@iconify/vue": "^4.1.0",
"@vueuse/core": "^11.0.0",
"apexcharts": "^5.15.0",
"axios": "^1.15.0",
"pinia": "^3.0.4",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"vue3-apexcharts": "^1.11.1",
"vue3-perfect-scrollbar": "^2.0.0",
"vuetify": "^3.7.5"
},
@@ -4281,6 +4283,11 @@
"node": ">= 8"
}
},
"node_modules/apexcharts": {
"version": "5.15.0",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.15.0.tgz",
"integrity": "sha512-ATswoiZi8wynKcwqf0eyE0Is5mBQoDpxZN3PlSVy41OrD+9GMBp2kZQRjyGK+5/j0GFjHkuAd7GKOrt5VMoPrw=="
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -11130,6 +11137,20 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue3-apexcharts": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/vue3-apexcharts/-/vue3-apexcharts-1.11.1.tgz",
"integrity": "sha512-MbN3vg8bMG19wc0Lm1HkeQvODgLm56DgpIxtNUO0xpf/JCzYWVGE4jzXp2JISzy2s3Kul1yOxNQUYsLvKQ5L9g==",
"peerDependencies": {
"apexcharts": ">=5.10.0",
"vue": ">=3.0.0"
},
"peerDependenciesMeta": {
"apexcharts": {
"optional": false
}
}
},
"node_modules/vue3-perfect-scrollbar": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue3-perfect-scrollbar/-/vue3-perfect-scrollbar-2.0.0.tgz",

View File

@@ -14,10 +14,12 @@
"@iconify-json/ri": "^1.2.0",
"@iconify/vue": "^4.1.0",
"@vueuse/core": "^11.0.0",
"apexcharts": "^5.15.0",
"axios": "^1.15.0",
"pinia": "^3.0.4",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"vue3-apexcharts": "^1.11.1",
"vue3-perfect-scrollbar": "^2.0.0",
"vuetify": "^3.7.5"
},

View File

@@ -0,0 +1,7 @@
import apiClient from './client';
import type { DashboardUsageResponse } from '@/types/api';
export async function getUsageDashboard(environmentId: number, days = 14): Promise<DashboardUsageResponse> {
const { data } = await apiClient.get<DashboardUsageResponse>(`/v1/environments/${environmentId}/usage`, { params: { days } });
return data;
}

View File

@@ -0,0 +1,119 @@
<template>
<apexchart type="bar" :options="chartOptions" :series="series" height="280" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { DailyUsage } from '@/types/api';
const props = defineProps<{
data: DailyUsage[];
}>();
const series = computed(() => [
{
name: 'Evaluations',
data: props.data.map((d) => d.totalCount),
},
]);
function formatDate(iso: string): string {
const parts = iso.split('-');
return parts.length === 3 ? `${parts[1]}/${parts[2]}` : iso;
}
const chartOptions = computed(() => {
const dailyData = props.data;
return {
chart: {
type: 'bar',
toolbar: { show: false },
animations: { enabled: false },
},
plotOptions: {
bar: {
columnWidth: '55%',
borderRadius: 4,
},
},
dataLabels: { enabled: false },
xaxis: {
categories: dailyData.map((d) => d.date),
labels: {
style: { colors: '#8A8D93', fontSize: '12px' },
formatter: (val: string) => formatDate(val),
},
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: {
labels: { style: { colors: '#8A8D93', fontSize: '12px' } },
},
colors: ['#8C57FF'],
grid: { borderColor: '#E0E0E0', strokeDashArray: 4 },
tooltip: {
custom: ({ dataPointIndex }: { dataPointIndex: number }) => {
const day = dailyData[dataPointIndex];
if (!day) return '';
const flagRows = day.features
.slice(0, 10)
.map(
(f) =>
`<div class="apex-day-tooltip__row"><span>${f.featureName}</span><strong>${f.count.toLocaleString()}</strong></div>`,
)
.join('');
const moreCount = day.features.length - 10;
const moreRow = moreCount > 0 ? `<div class="apex-day-tooltip__more">+${moreCount} more</div>` : '';
return `
<div class="apex-day-tooltip">
<div class="apex-day-tooltip__header">${day.date} &mdash; ${day.totalCount.toLocaleString()} total</div>
${flagRows}${moreRow}
</div>`;
},
},
};
});
</script>
<style>
.apex-day-tooltip {
padding: 10px 14px;
font-family: inherit;
min-width: 200px;
max-width: 300px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.apex-day-tooltip__header {
font-weight: 600;
margin-bottom: 8px;
font-size: 0.82rem;
color: #2e263d;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 6px;
}
.apex-day-tooltip__row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
font-size: 0.8rem;
padding: 2px 0;
color: #555;
}
.apex-day-tooltip__row strong {
color: #8c57ff;
font-weight: 600;
}
.apex-day-tooltip__more {
font-size: 0.75rem;
color: #9e9e9e;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<apexchart type="bar" :options="chartOptions" :series="series" height="280" />
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { TopFeatureUsage } from '@/types/api';
const props = defineProps<{
data: TopFeatureUsage[];
}>();
const series = computed(() => [
{
name: 'Evaluations',
data: props.data.map((f) => f.count),
},
]);
const chartOptions = computed(() => ({
chart: {
type: 'bar',
toolbar: { show: false },
animations: { enabled: false },
},
plotOptions: {
bar: {
horizontal: true,
borderRadius: 4,
dataLabels: { position: 'top' },
},
},
dataLabels: { enabled: false },
xaxis: {
categories: props.data.map((f) => f.featureName),
labels: { style: { colors: '#8A8D93', fontSize: '12px' } },
axisBorder: { show: false },
axisTicks: { show: false },
},
yaxis: {
labels: { style: { colors: '#8A8D93', fontSize: '12px' } },
},
colors: ['#8C57FF'],
grid: { borderColor: '#E0E0E0', strokeDashArray: 4, yaxis: { lines: { show: false } } },
tooltip: {
y: { formatter: (val: number) => `${val.toLocaleString()} evaluations` },
},
}));
</script>

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import VueApexCharts from 'vue3-apexcharts';
import App from './App.vue';
// Styles
@@ -15,6 +16,7 @@ const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(VueApexCharts);
installVuetify(app);
// Restore persisted auth and context state before the first route navigation

View File

@@ -476,6 +476,25 @@ export interface FlagResponse {
featureStateValue: string | null;
}
// ─── Feature Usage ─────────────────────────────────────────────────────────────
export interface TopFeatureUsage {
featureId: number;
featureName: string;
count: number;
}
export interface DailyUsage {
date: string;
totalCount: number;
features: TopFeatureUsage[];
}
export interface DashboardUsageResponse {
topFeaturesLastDay: TopFeatureUsage[];
dailyUsage: DailyUsage[];
}
// ─── Errors ───────────────────────────────────────────────────────────────────
export interface ApiValidationError {

View File

@@ -71,6 +71,61 @@
</v-col>
</v-row>
<!-- Usage charts (requires environment) -->
<template v-if="contextStore.currentEnvironment">
<v-row class="mb-4">
<!-- Top 10 features last day -->
<v-col cols="12" md="6">
<v-card rounded="lg" data-testid="top-features-chart-card">
<v-card-item class="pb-0">
<v-card-title class="text-body-1 font-weight-medium">Top Features (Last Day)</v-card-title>
<v-card-subtitle class="text-caption">Most evaluated flags in the last 24 hours</v-card-subtitle>
</v-card-item>
<v-card-text class="pt-2">
<div v-if="isLoadingUsage" class="d-flex align-center justify-center" style="height:280px">
<v-progress-circular indeterminate color="primary" />
</div>
<div v-else-if="usageError" class="d-flex align-center justify-center text-medium-emphasis" style="height:280px">
<span class="text-caption">Failed to load usage data</span>
</div>
<div v-else-if="!usageData || usageData.topFeaturesLastDay.length === 0" class="d-flex align-center justify-center text-medium-emphasis" style="height:280px">
<div class="text-center">
<v-icon size="36" color="medium-emphasis" class="mb-2">ri-bar-chart-line</v-icon>
<p class="text-caption">No evaluations recorded yet</p>
</div>
</div>
<TopFeaturesChart v-else :data="usageData.topFeaturesLastDay" />
</v-card-text>
</v-card>
</v-col>
<!-- Usage by day -->
<v-col cols="12" md="6">
<v-card rounded="lg" data-testid="daily-usage-chart-card">
<v-card-item class="pb-0">
<v-card-title class="text-body-1 font-weight-medium">Evaluations by Day</v-card-title>
<v-card-subtitle class="text-caption">Hover a bar to see per-flag counts · Last 14 days</v-card-subtitle>
</v-card-item>
<v-card-text class="pt-2">
<div v-if="isLoadingUsage" class="d-flex align-center justify-center" style="height:280px">
<v-progress-circular indeterminate color="primary" />
</div>
<div v-else-if="usageError" class="d-flex align-center justify-center text-medium-emphasis" style="height:280px">
<span class="text-caption">Failed to load usage data</span>
</div>
<div v-else-if="!usageData || usageData.dailyUsage.length === 0" class="d-flex align-center justify-center text-medium-emphasis" style="height:280px">
<div class="text-center">
<v-icon size="36" color="medium-emphasis" class="mb-2">ri-bar-chart-2-line</v-icon>
<p class="text-caption">No evaluations recorded yet</p>
</div>
</div>
<DailyUsageChart v-else :data="usageData.dailyUsage" />
</v-card-text>
</v-card>
</v-col>
</v-row>
</template>
<!-- Quick-nav cards -->
<v-row v-if="contextStore.currentProject">
<v-col
@@ -124,10 +179,38 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useContextStore } from '@/stores/context';
import { getUsageDashboard } from '@/api/usage';
import TopFeaturesChart from '@/components/dashboard/TopFeaturesChart.vue';
import DailyUsageChart from '@/components/dashboard/DailyUsageChart.vue';
import type { DashboardUsageResponse } from '@/types/api';
const contextStore = useContextStore();
const usageData = ref<DashboardUsageResponse | null>(null);
const isLoadingUsage = ref(false);
const usageError = ref(false);
async function loadUsage(): Promise<void> {
const envId = contextStore.currentEnvironment?.id;
if (!envId) {
usageData.value = null;
return;
}
isLoadingUsage.value = true;
usageError.value = false;
try {
usageData.value = await getUsageDashboard(envId);
} catch {
usageError.value = true;
} finally {
isLoadingUsage.value = false;
}
}
watch(() => contextStore.currentEnvironment, loadUsage, { immediate: true });
const sections = [
{
label: 'Features',

View File

@@ -0,0 +1,19 @@
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class FeatureUsageDailyConfiguration : IEntityTypeConfiguration<FeatureUsageDaily>
{
public void Configure(EntityTypeBuilder<FeatureUsageDaily> builder)
{
builder.HasKey(u => u.Id);
builder.Property(u => u.FeatureName).HasMaxLength(150).IsRequired();
builder.Property(u => u.UpdatedAt).IsRequired();
builder.Property(u => u.Count).IsRequired();
builder.HasIndex(u => new { u.EnvironmentId, u.FeatureId, u.UsageDate }).IsUnique();
builder.HasIndex(u => new { u.EnvironmentId, u.UsageDate });
}
}

View File

@@ -37,6 +37,7 @@ public class MicCheckDbContext : DbContext
public DbSet<User> Users => Set<User>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<UserProjectPermission> UserProjectPermissions => Set<UserProjectPermission>();
public DbSet<FeatureUsageDaily> FeatureUsageDaily => Set<FeatureUsageDaily>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);

View File

@@ -8,7 +8,8 @@ namespace MicCheck.Api.Features;
public class FeatureEvaluationService(
MicCheckDbContext db,
SegmentEvaluator segmentEvaluator,
FlagCache flagCache)
FlagCache flagCache,
FeatureUsageMetrics usageMetrics)
{
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
int environmentId, CancellationToken ct = default)
@@ -32,6 +33,10 @@ public class FeatureEvaluationService(
).ToListAsync(ct);
flagCache.Set(environmentId, results);
foreach (var r in results)
usageMetrics.RecordEvaluation(environmentId, r.Feature.Id, r.Feature.Name);
return results;
}
@@ -134,6 +139,9 @@ public class FeatureEvaluationService(
}
}
foreach (var r in results)
usageMetrics.RecordEvaluation(environmentId, r.Feature.Id, r.Feature.Name);
return results;
}
}

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Features;
public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate);

View File

@@ -0,0 +1,32 @@
using MicCheck.Api.Common.Security.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/environments/{environmentId}/usage")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(
int environmentId,
[FromQuery] int days = 14,
CancellationToken ct = default)
{
days = Math.Clamp(days, 1, 90);
var cacheKey = $"usage-dashboard:{environmentId}:{days}";
if (cache.TryGetValue(cacheKey, out DashboardUsageResponse? cached))
return Ok(cached);
var result = await queryService.GetDashboardUsageAsync(environmentId, days, ct);
cache.Set(cacheKey, result, TimeSpan.FromSeconds(180));
return Ok(result);
}
}

View File

@@ -0,0 +1,12 @@
namespace MicCheck.Api.Features;
public class FeatureUsageDaily
{
public int Id { get; init; }
public int EnvironmentId { get; init; }
public int FeatureId { get; init; }
public required string FeatureName { get; set; }
public DateOnly UsageDate { get; init; }
public long Count { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,60 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureUsageFlushBackgroundService(
FeatureUsageMetrics metrics,
IServiceScopeFactory scopeFactory,
ILogger<FeatureUsageFlushBackgroundService> logger) : BackgroundService
{
private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(FlushInterval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await FlushAsync(stoppingToken);
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
await FlushAsync(CancellationToken.None);
}
private async Task FlushAsync(CancellationToken ct)
{
var deltas = metrics.DrainAccumulated();
if (deltas.Count == 0)
return;
var updatedAt = DateTimeOffset.UtcNow;
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
foreach (var (key, count) in deltas)
{
await db.Database.ExecuteSqlAsync(
$"""
INSERT INTO "FeatureUsageDaily" ("EnvironmentId", "FeatureId", "FeatureName", "UsageDate", "Count", "UpdatedAt")
VALUES ({key.EnvironmentId}, {key.FeatureId}, {key.FeatureName}, {key.UsageDate}, {count}, {updatedAt})
ON CONFLICT ("EnvironmentId", "FeatureId", "UsageDate")
DO UPDATE SET "Count" = "FeatureUsageDaily"."Count" + EXCLUDED."Count",
"FeatureName" = EXCLUDED."FeatureName",
"UpdatedAt" = EXCLUDED."UpdatedAt"
""",
ct);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to flush feature usage data ({BucketCount} buckets)", deltas.Count);
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
namespace MicCheck.Api.Features;
public class FeatureUsageMetrics : IDisposable
{
private readonly Meter _meter;
private readonly Counter<long> _evaluationCounter;
private ConcurrentDictionary<FeatureUsageBucketKey, long> _accumulator = new();
public FeatureUsageMetrics(IMeterFactory meterFactory)
{
_meter = meterFactory.Create("MicCheck.FeatureUsage");
_evaluationCounter = _meter.CreateCounter<long>("miccheck.feature.evaluations", description: "Number of feature flag evaluations");
}
public void RecordEvaluation(int environmentId, int featureId, string featureName)
{
_evaluationCounter.Add(1,
new KeyValuePair<string, object?>("environment.id", environmentId),
new KeyValuePair<string, object?>("feature.id", featureId));
var key = new FeatureUsageBucketKey(environmentId, featureId, featureName, DateOnly.FromDateTime(DateTime.UtcNow));
_accumulator.AddOrUpdate(key, 1L, (_, existing) => existing + 1);
}
public IReadOnlyDictionary<FeatureUsageBucketKey, long> DrainAccumulated()
{
var drained = Interlocked.Exchange(ref _accumulator, new ConcurrentDictionary<FeatureUsageBucketKey, long>());
return drained;
}
public void Dispose() => _meter.Dispose();
}

View File

@@ -0,0 +1,42 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureUsageQueryService(MicCheckDbContext db)
{
public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var windowStart = today.AddDays(-days);
var yesterday = today.AddDays(-1);
var rows = await db.FeatureUsageDaily
.Where(u => u.EnvironmentId == environmentId && u.UsageDate >= windowStart)
.ToListAsync(ct);
var topFeaturesLastDay = rows
.Where(u => u.UsageDate >= yesterday)
.GroupBy(u => new { u.FeatureId, u.FeatureName })
.Select(g => new TopFeatureUsage(g.Key.FeatureId, g.Key.FeatureName, g.Sum(u => u.Count)))
.OrderByDescending(t => t.Count)
.Take(10)
.ToList();
var dailyUsage = rows
.GroupBy(u => u.UsageDate)
.OrderBy(g => g.Key)
.Select(g =>
{
var features = g
.GroupBy(u => new { u.FeatureId, u.FeatureName })
.Select(fg => new TopFeatureUsage(fg.Key.FeatureId, fg.Key.FeatureName, fg.Sum(u => u.Count)))
.OrderByDescending(f => f.Count)
.ToList();
return new DailyUsage(g.Key, features.Sum(f => f.Count), features);
})
.ToList();
return new DashboardUsageResponse(topFeaturesLastDay, dailyUsage);
}
}

View File

@@ -0,0 +1,9 @@
namespace MicCheck.Api.Features;
public record TopFeatureUsage(int FeatureId, string FeatureName, long Count);
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
public record DashboardUsageResponse(
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
IReadOnlyList<DailyUsage> DailyUsage);

View File

@@ -0,0 +1,979 @@
// <auto-generated />
using System;
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MicCheck.Api.Migrations
{
[DbContext(typeof(MicCheckDbContext))]
[Migration("20260617194113_AddFeatureUsageDaily")]
partial class AddFeatureUsageDaily
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FeatureTags", b =>
{
b.Property<int>("FeatureId")
.HasColumnType("integer");
b.Property<int>("TagsId")
.HasColumnType("integer");
b.HasKey("FeatureId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("FeatureTags");
});
modelBuilder.Entity("MicCheck.Api.Audit.AuditLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int?>("ActorUserId")
.HasColumnType("integer");
b.Property<string>("Changes")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("EnvironmentId")
.HasColumnType("integer");
b.Property<int>("OrganizationId")
.HasColumnType("integer");
b.Property<int?>("ProjectId")
.HasColumnType("integer");
b.Property<string>("ResourceId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ResourceType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("OrganizationId", "CreatedAt");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("MicCheck.Api.Common.Security.ApiKeys.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("OrganizationId")
.HasColumnType("integer");
b.Property<string>("Prefix")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.HasIndex("OrganizationId");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("MicCheck.Api.Common.Security.Authorization.UserProjectPermission", b =>
{
b.Property<int>("UserId")
.HasColumnType("integer");
b.Property<int>("ProjectId")
.HasColumnType("integer");
b.Property<bool>("IsAdmin")
.HasColumnType("boolean");
b.Property<string>("Permissions")
.IsRequired()
.HasColumnType("text");
b.HasKey("UserId", "ProjectId");
b.ToTable("UserProjectPermissions");
});
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ApiKey")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("ProjectId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ApiKey")
.IsUnique();
b.HasIndex("ProjectId");
b.ToTable("Environments");
});
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DefaultEnabled")
.HasColumnType("boolean");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("InitialValue")
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<int>("ProjectId")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProjectId", "Name")
.IsUnique();
b.ToTable("Features");
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("EnvironmentId")
.HasColumnType("integer");
b.Property<int>("FeatureId")
.HasColumnType("integer");
b.Property<int>("Priority")
.HasColumnType("integer");
b.Property<int>("SegmentId")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("FeatureSegments");
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<int>("EnvironmentId")
.HasColumnType("integer");
b.Property<int>("FeatureId")
.HasColumnType("integer");
b.Property<int?>("FeatureSegmentId")
.HasColumnType("integer");
b.Property<int?>("IdentityId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Value")
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("EnvironmentId");
b.HasIndex("FeatureSegmentId")
.IsUnique();
b.HasIndex("IdentityId");
b.HasIndex("FeatureId", "EnvironmentId", "IdentityId")
.IsUnique();
b.ToTable("FeatureStates");
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("Count")
.HasColumnType("bigint");
b.Property<int>("EnvironmentId")
.HasColumnType("integer");
b.Property<int>("FeatureId")
.HasColumnType("integer");
b.Property<string>("FeatureName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("UsageDate")
.HasColumnType("date");
b.HasKey("Id");
b.HasIndex("EnvironmentId", "UsageDate");
b.HasIndex("EnvironmentId", "FeatureId", "UsageDate")
.IsUnique();
b.ToTable("FeatureUsageDaily");
});
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Color")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("ProjectId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Tags");
});
modelBuilder.Entity("MicCheck.Api.Identities.Identity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("EnvironmentId")
.HasColumnType("integer");
b.Property<string>("Identifier")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.HasKey("Id");
b.HasIndex("EnvironmentId", "Identifier")
.IsUnique();
b.ToTable("Identities");
});
modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("IdentityId")
.HasColumnType("integer");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("ValueType")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("IdentityId", "Key")
.IsUnique();
b.ToTable("IdentityTraits");
});
modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InviteToken")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("InviteToken")
.IsUnique()
.HasFilter("\"InviteToken\" IS NOT NULL");
b.ToTable("Organizations");
});
modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
{
b.Property<int>("OrganizationId")
.HasColumnType("integer");
b.Property<int>("UserId")
.HasColumnType("integer");
b.Property<bool>("IsPrimary")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("OrganizationId", "UserId");
b.HasIndex("UserId");
b.ToTable("OrganizationUsers");
});
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("HideDisabledFlags")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("OrganizationId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("Projects");
});
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("ProjectId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Segments");
});
modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Operator")
.HasColumnType("integer");
b.Property<string>("Property")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("RuleId")
.HasColumnType("integer");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.HasKey("Id");
b.HasIndex("RuleId");
b.ToTable("SegmentConditions");
});
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("ParentRuleId")
.HasColumnType("integer");
b.Property<int>("SegmentId")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ParentRuleId");
b.HasIndex("SegmentId");
b.ToTable("SegmentRules");
});
modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsRevoked")
.HasColumnType("boolean");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("MicCheck.Api.Users.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsTwoFactorEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("MicCheck.Api.Webhooks.Webhook", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("Enabled")
.HasColumnType("boolean");
b.Property<int?>("EnvironmentId")
.HasColumnType("integer");
b.Property<int?>("OrganizationId")
.HasColumnType("integer");
b.Property<int>("Scope")
.HasColumnType("integer");
b.Property<string>("Secret")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Id");
b.HasIndex("EnvironmentId");
b.ToTable("Webhooks");
});
modelBuilder.Entity("MicCheck.Api.Webhooks.WebhookDeliveryLog", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AttemptNumber")
.HasColumnType("integer");
b.Property<DateTimeOffset>("AttemptedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasColumnType("text");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PayloadJson")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ResponseBody")
.HasColumnType("text");
b.Property<int?>("ResponseStatusCode")
.HasColumnType("integer");
b.Property<bool>("Success")
.HasColumnType("boolean");
b.Property<int>("WebhookId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("WebhookId");
b.ToTable("WebhookDeliveryLogs");
});
modelBuilder.Entity("FeatureTags", b =>
{
b.HasOne("MicCheck.Api.Features.Feature", null)
.WithMany()
.HasForeignKey("FeatureId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MicCheck.Api.Features.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Common.Security.ApiKeys.ApiKey", b =>
{
b.HasOne("MicCheck.Api.Organizations.Organization", null)
.WithMany("ApiKeys")
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
{
b.HasOne("MicCheck.Api.Projects.Project", null)
.WithMany("Environments")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
{
b.HasOne("MicCheck.Api.Projects.Project", null)
.WithMany("Features")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureState", b =>
{
b.HasOne("MicCheck.Api.Environments.Environment", null)
.WithMany("FeatureStates")
.HasForeignKey("EnvironmentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MicCheck.Api.Features.Feature", null)
.WithMany("FeatureStates")
.HasForeignKey("FeatureId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MicCheck.Api.Features.FeatureSegment", null)
.WithOne("FeatureState")
.HasForeignKey("MicCheck.Api.Features.FeatureState", "FeatureSegmentId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MicCheck.Api.Identities.Identity", null)
.WithMany("FeatureStateOverrides")
.HasForeignKey("IdentityId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
{
b.HasOne("MicCheck.Api.Projects.Project", null)
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Identities.IdentityTrait", b =>
{
b.HasOne("MicCheck.Api.Identities.Identity", null)
.WithMany("Traits")
.HasForeignKey("IdentityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Organizations.OrganizationUser", b =>
{
b.HasOne("MicCheck.Api.Organizations.Organization", null)
.WithMany("Members")
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MicCheck.Api.Users.User", "User")
.WithMany("Organizations")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
{
b.HasOne("MicCheck.Api.Organizations.Organization", null)
.WithMany("Projects")
.HasForeignKey("OrganizationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
{
b.HasOne("MicCheck.Api.Projects.Project", null)
.WithMany("Segments")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Segments.SegmentCondition", b =>
{
b.HasOne("MicCheck.Api.Segments.SegmentRule", null)
.WithMany("Conditions")
.HasForeignKey("RuleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
{
b.HasOne("MicCheck.Api.Segments.SegmentRule", null)
.WithMany("ChildRules")
.HasForeignKey("ParentRuleId")
.OnDelete(DeleteBehavior.NoAction);
b.HasOne("MicCheck.Api.Segments.Segment", null)
.WithMany("Rules")
.HasForeignKey("SegmentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MicCheck.Api.Users.RefreshToken", b =>
{
b.HasOne("MicCheck.Api.Users.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("MicCheck.Api.Environments.Environment", b =>
{
b.Navigation("FeatureStates");
});
modelBuilder.Entity("MicCheck.Api.Features.Feature", b =>
{
b.Navigation("FeatureStates");
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureSegment", b =>
{
b.Navigation("FeatureState");
});
modelBuilder.Entity("MicCheck.Api.Identities.Identity", b =>
{
b.Navigation("FeatureStateOverrides");
b.Navigation("Traits");
});
modelBuilder.Entity("MicCheck.Api.Organizations.Organization", b =>
{
b.Navigation("ApiKeys");
b.Navigation("Members");
b.Navigation("Projects");
});
modelBuilder.Entity("MicCheck.Api.Projects.Project", b =>
{
b.Navigation("Environments");
b.Navigation("Features");
b.Navigation("Segments");
});
modelBuilder.Entity("MicCheck.Api.Segments.Segment", b =>
{
b.Navigation("Rules");
});
modelBuilder.Entity("MicCheck.Api.Segments.SegmentRule", b =>
{
b.Navigation("ChildRules");
b.Navigation("Conditions");
});
modelBuilder.Entity("MicCheck.Api.Users.User", b =>
{
b.Navigation("Organizations");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace MicCheck.Api.Migrations
{
/// <inheritdoc />
public partial class AddFeatureUsageDaily : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FeatureUsageDaily",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
EnvironmentId = table.Column<int>(type: "integer", nullable: false),
FeatureId = table.Column<int>(type: "integer", nullable: false),
FeatureName = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
UsageDate = table.Column<DateOnly>(type: "date", nullable: false),
Count = table.Column<long>(type: "bigint", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FeatureUsageDaily", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_FeatureUsageDaily_EnvironmentId_FeatureId_UsageDate",
table: "FeatureUsageDaily",
columns: new[] { "EnvironmentId", "FeatureId", "UsageDate" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_FeatureUsageDaily_EnvironmentId_UsageDate",
table: "FeatureUsageDaily",
columns: new[] { "EnvironmentId", "UsageDate" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FeatureUsageDaily");
}
}
}

View File

@@ -302,6 +302,44 @@ namespace MicCheck.Api.Migrations
b.ToTable("FeatureStates");
});
modelBuilder.Entity("MicCheck.Api.Features.FeatureUsageDaily", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("Count")
.HasColumnType("bigint");
b.Property<int>("EnvironmentId")
.HasColumnType("integer");
b.Property<int>("FeatureId")
.HasColumnType("integer");
b.Property<string>("FeatureName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("UsageDate")
.HasColumnType("date");
b.HasKey("Id");
b.HasIndex("EnvironmentId", "UsageDate");
b.HasIndex("EnvironmentId", "FeatureId", "UsageDate")
.IsUnique();
b.ToTable("FeatureUsageDaily");
});
modelBuilder.Entity("MicCheck.Api.Features.Tag", b =>
{
b.Property<int>("Id")

View File

@@ -109,6 +109,7 @@ try
});
builder.Services.AddMemoryCache();
builder.Services.AddMetrics();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<AuthService>();
@@ -138,6 +139,9 @@ try
builder.Services.AddSingleton<WebhookQueue>();
builder.Services.AddHostedService<WebhookBackgroundService>();
builder.Services.AddHostedService<WebhookRetryBackgroundService>();
builder.Services.AddSingleton<FeatureUsageMetrics>();
builder.Services.AddScoped<FeatureUsageQueryService>();
builder.Services.AddHostedService<FeatureUsageFlushBackgroundService>();
builder.Services.AddHttpClient("Webhooks", client =>
client.DefaultRequestHeaders.Add("User-Agent", "MicCheck-Webhook/1.0"));

View File

@@ -1,9 +1,11 @@
using System.Diagnostics.Metrics;
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Moq;
using NUnit.Framework;
using AppEnvironment = MicCheck.Api.Environments.Environment;
@@ -14,6 +16,7 @@ public class FeatureEvaluationServiceTests
{
private MicCheckDbContext _db = null!;
private FeatureEvaluationService _service = null!;
private FeatureUsageMetrics _usageMetrics = null!;
private const int EnvironmentId = 1;
private const int ProjectId = 1;
@@ -25,14 +28,22 @@ public class FeatureEvaluationServiceTests
.Options;
_db = new MicCheckDbContext(options);
var meterFactory = new Mock<IMeterFactory>();
meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test"));
_usageMetrics = new FeatureUsageMetrics(meterFactory.Object);
var cache = new FlagCache(new MemoryCache(new MemoryCacheOptions()));
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache);
_service = new FeatureEvaluationService(_db, new SegmentEvaluator(), cache, _usageMetrics);
SeedBaseData();
}
[TearDown]
public void TearDown() => _db.Dispose();
public void TearDown()
{
_db.Dispose();
_usageMetrics.Dispose();
}
private void SeedBaseData()
{

View File

@@ -0,0 +1,80 @@
using System.Diagnostics.Metrics;
using MicCheck.Api.Features;
using Moq;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureUsageMetricsTests
{
private FeatureUsageMetrics _metrics = null!;
[SetUp]
public void SetUp()
{
var meterFactory = new Mock<IMeterFactory>();
meterFactory.Setup(f => f.Create(It.IsAny<MeterOptions>())).Returns(new Meter("test"));
_metrics = new FeatureUsageMetrics(meterFactory.Object);
}
[TearDown]
public void TearDown() => _metrics.Dispose();
[Test]
public void WhenRecordingEvaluations_ThenAccumulatorIncrementsByFeatureAndEnvironment()
{
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
_metrics.RecordEvaluation(environmentId: 1, featureId: 20, featureName: "beta");
_metrics.RecordEvaluation(environmentId: 2, featureId: 10, featureName: "dark_mode");
var drained = _metrics.DrainAccumulated();
Assert.That(drained, Has.Count.EqualTo(3));
var today = DateOnly.FromDateTime(DateTime.UtcNow);
Assert.That(drained[new FeatureUsageBucketKey(1, 10, "dark_mode", today)], Is.EqualTo(2));
Assert.That(drained[new FeatureUsageBucketKey(1, 20, "beta", today)], Is.EqualTo(1));
Assert.That(drained[new FeatureUsageBucketKey(2, 10, "dark_mode", today)], Is.EqualTo(1));
}
[Test]
public void WhenDrainCalled_ThenAccumulatorIsResetToEmpty()
{
_metrics.RecordEvaluation(environmentId: 1, featureId: 10, featureName: "dark_mode");
_metrics.DrainAccumulated();
var secondDrain = _metrics.DrainAccumulated();
Assert.That(secondDrain, Is.Empty);
}
[Test]
public void WhenNothingRecorded_ThenDrainReturnsEmptyDictionary()
{
var drained = _metrics.DrainAccumulated();
Assert.That(drained, Is.Empty);
}
[Test]
public void WhenRecordingConcurrently_ThenCountsAreAccurateAndNoDataLost()
{
const int threads = 8;
const int recordsPerThread = 100;
var tasks = Enumerable.Range(0, threads).Select(_ =>
Task.Run(() =>
{
for (var i = 0; i < recordsPerThread; i++)
_metrics.RecordEvaluation(environmentId: 1, featureId: 1, featureName: "concurrent_flag");
})).ToArray();
Task.WaitAll(tasks);
var drained = _metrics.DrainAccumulated();
var today = DateOnly.FromDateTime(DateTime.UtcNow);
Assert.That(drained[new FeatureUsageBucketKey(1, 1, "concurrent_flag", today)], Is.EqualTo(threads * recordsPerThread));
}
}

View File

@@ -0,0 +1,138 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
namespace MicCheck.Api.Tests.Unit.Features;
[TestFixture]
public class FeatureUsageQueryServiceTests
{
private MicCheckDbContext _db = null!;
private FeatureUsageQueryService _service = null!;
private const int EnvironmentId = 1;
[SetUp]
public void SetUp()
{
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
_db = new MicCheckDbContext(options);
_service = new FeatureUsageQueryService(_db);
}
[TearDown]
public void TearDown() => _db.Dispose();
private void SeedUsage(int environmentId, int featureId, string featureName, DateOnly date, long count)
{
_db.FeatureUsageDaily.Add(new FeatureUsageDaily
{
EnvironmentId = environmentId,
FeatureId = featureId,
FeatureName = featureName,
UsageDate = date,
Count = count,
UpdatedAt = DateTimeOffset.UtcNow
});
_db.SaveChanges();
}
[Test]
public async Task WhenNoUsageData_ThenEmptyCollectionsAreReturned()
{
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 14);
Assert.That(result.TopFeaturesLastDay, Is.Empty);
Assert.That(result.DailyUsage, Is.Empty);
}
[Test]
public async Task WhenMultipleFeaturesEvaluatedToday_ThenTopFeaturesLastDayOrdersByCountDescending()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
SeedUsage(EnvironmentId, featureId: 1, "feature_a", today, count: 50);
SeedUsage(EnvironmentId, featureId: 2, "feature_b", today, count: 200);
SeedUsage(EnvironmentId, featureId: 3, "feature_c", today, count: 10);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
Assert.That(result.TopFeaturesLastDay, Has.Count.EqualTo(3));
Assert.That(result.TopFeaturesLastDay[0].FeatureName, Is.EqualTo("feature_b"));
Assert.That(result.TopFeaturesLastDay[0].Count, Is.EqualTo(200));
Assert.That(result.TopFeaturesLastDay[1].FeatureName, Is.EqualTo("feature_a"));
Assert.That(result.TopFeaturesLastDay[2].FeatureName, Is.EqualTo("feature_c"));
}
[Test]
public async Task WhenMoreThanTenFeatures_ThenTopFeaturesLastDayLimitsToTen()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
for (var i = 1; i <= 15; i++)
SeedUsage(EnvironmentId, featureId: i, $"feature_{i}", today, count: i * 10L);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
Assert.That(result.TopFeaturesLastDay, Has.Count.EqualTo(10));
}
[Test]
public async Task WhenUsageSpansMultipleDays_ThenDailyUsageGroupsByDateAscending()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-2), count: 30);
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-1), count: 50);
SeedUsage(EnvironmentId, featureId: 1, "flag", today, count: 20);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
Assert.That(result.DailyUsage, Has.Count.EqualTo(3));
Assert.That(result.DailyUsage[0].Date, Is.EqualTo(today.AddDays(-2)));
Assert.That(result.DailyUsage[0].TotalCount, Is.EqualTo(30));
Assert.That(result.DailyUsage[1].TotalCount, Is.EqualTo(50));
Assert.That(result.DailyUsage[2].TotalCount, Is.EqualTo(20));
}
[Test]
public async Task WhenHoveringADay_ThenDailyUsageIncludesPerFeatureBreakdownOrderedByCount()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
SeedUsage(EnvironmentId, featureId: 1, "feature_a", today, count: 10);
SeedUsage(EnvironmentId, featureId: 2, "feature_b", today, count: 40);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 1);
var day = result.DailyUsage.Single();
Assert.That(day.TotalCount, Is.EqualTo(50));
Assert.That(day.Features, Has.Count.EqualTo(2));
Assert.That(day.Features[0].FeatureName, Is.EqualTo("feature_b"));
Assert.That(day.Features[0].Count, Is.EqualTo(40));
Assert.That(day.Features[1].FeatureName, Is.EqualTo("feature_a"));
}
[Test]
public async Task WhenUsageExistsOutsideDaysWindow_ThenOldDataIsExcluded()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-30), count: 999);
SeedUsage(EnvironmentId, featureId: 1, "flag", today.AddDays(-3), count: 5);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
Assert.That(result.DailyUsage, Has.Count.EqualTo(1));
Assert.That(result.DailyUsage[0].TotalCount, Is.EqualTo(5));
}
[Test]
public async Task WhenOtherEnvironmentHasUsage_ThenItIsNotIncludedInResults()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
SeedUsage(environmentId: 99, featureId: 1, "flag", today, count: 100);
var result = await _service.GetDashboardUsageAsync(EnvironmentId, days: 7);
Assert.That(result.TopFeaturesLastDay, Is.Empty);
Assert.That(result.DailyUsage, Is.Empty);
}
}