Skip to content

Commit 68af1ce

Browse files
CopilotkarpikplCopilot
authored
fix: normalize 1-day flat response in downloadReport and honor SYNC_DAYS_BACK in syncBulk (#345)
* Initial plan * fix: normalize 1-day flat ReportDayTotals in downloadReport to fix sync-date error Agent-Logs-Url: https://github.com/github-copilot-resources/copilot-metrics-viewer/sessions/90497913-2ed4-4b1a-9a3a-26c22511765a Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> * fix: honor SYNC_DAYS_BACK env var in syncBulk via new daysBack parameter Agent-Logs-Url: https://github.com/github-copilot-resources/copilot-metrics-viewer/sessions/697cd2bc-3055-4d55-818c-c74a19f96c64 Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> * fix: bump version to 3.1.1 (bugfix patch release) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: pass daysBack as correct positional arg to syncBulk SYNC_DAYS_BACK env var was being passed as the 4th argument (teamSlug) instead of the 5th (daysBack), so it had no effect. Pass undefined for teamSlug explicitly so daysBack lands in the correct position. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove unused variable in sync-storage test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document admin sync API actions in DEPLOYMENT.md Add Admin Sync API section explaining POST /api/admin/sync with all four actions (sync-date, sync-bulk, sync-range, sync-gaps), their required parameters, and curl examples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: parse JSON body even when Content-Type header is missing When curl (or any client) posts JSON without Content-Type: application/json, H3's readBody returns the raw string. Spreading a string gives per-character keys so 'action' is never found and the endpoint silently defaults to sync-date. Now explicitly JSON.parse the body if readBody returns a string, making all sync actions work without requiring the Content-Type header. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: sync-gaps now reports gapsDetected and outsideWindow counts Previously sync-gaps returned {gapsFilled:0, results:[]} with no indication of why nothing was filled. Now returns: - gapsDetected: total missing dates in the requested range - gapsFilled: dates successfully stored - outsideWindow: gaps that exist but fall outside the 28-day API window This makes it clear when gaps are genuinely absent vs when they exist but cannot be backfilled because the GitHub API only provides the last 28 days. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: sync-gaps now backfills historical dates via 1-day endpoint The 28-day bulk download only covers the last 28 days, so gaps older than that were silently skipped. Now syncGaps uses the bulk download for in-window gaps (efficient) and falls back to the 1-day endpoint for out-of-window gaps (one API call per date). This means sync-gaps can fill any historical date the API has data for. Also update DEPLOYMENT.md: clarify that sync-date/sync-range/sync-gaps all support historical backfill, Content-Type header is optional, and Authorization header is not needed when NUXT_GITHUB_TOKEN is configured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * rename: sync-bulk → sync-last-28 (keep sync-bulk as alias) The name 'sync-last-28' makes the behavior obvious — it downloads the latest 28-day bulk report. 'sync-bulk' is kept as a backward-compatible alias so any existing scripts continue to work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(user-metrics): clickable user badges with drill-down charts - User badges in the table are now clickable; clicking toggles selection (highlighted in indigo) and filters the 4 new charts - Added 'User Insights' section between the table and history: 1. Language Distribution (Doughnut) — from totals_by_language_feature 2. Feature Usage by Category (horizontal Bar) — completions/chat/agent 3. Top Models by Interactions (horizontal Bar) — from totals_by_model_feature 4. Activity Over Time (Line) — auto-loads user history on selection - Selection cleared via closable chip or clicking the badge again - Warning chip shown when selected user is hidden by activity filter - Race guard prevents stale trend data from earlier fetches Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: karpikpl <3539908+karpikpl@users.noreply.github.com> Co-authored-by: Piotr Karpala <piotrkarpala@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 57aa700 commit 68af1ce

11 files changed

Lines changed: 526 additions & 38 deletions

DEPLOYMENT.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,60 @@ These endpoints respond in ~200ms without making external API calls and do not r
281281
>[!NOTE]
282282
> Using these dedicated health endpoints instead of the root `/` path avoids triggering GitHub API calls during health checks.
283283
284+
### Admin Sync API
285+
286+
When running in Historical mode, the web app exposes a manual sync endpoint for backfilling or repairing data. If the app is configured with `NUXT_GITHUB_TOKEN`, the Authorization header is optional (the server uses its own token).
287+
288+
> **Note:** The GitHub Copilot Metrics API provides historical data well beyond the 28-day rolling window. The 1-day endpoint supports dates going back many months, so `sync-date`, `sync-range`, and `sync-gaps` can all backfill historical data. The 28-day limit only applies to `sync-last-28` (which uses the bulk download endpoint).
289+
290+
**`POST /api/admin/sync`**
291+
292+
Common parameters (body JSON or query string — `Content-Type: application/json` is optional):
293+
294+
| Parameter | Description |
295+
|-----------|-------------|
296+
| `scope` | `organization` or `enterprise` |
297+
| `githubOrg` | Organization slug (org scope) |
298+
| `githubEnt` | Enterprise slug (enterprise scope) |
299+
| `action` | One of the actions below (default: `sync-date`) |
300+
301+
#### Actions
302+
303+
**`sync-date`** — Download and store metrics for a single day (supports any historical date).
304+
305+
```bash
306+
curl -X POST http://localhost:3000/api/admin/sync \
307+
-H "Content-Type: application/json" \
308+
-d '{"action":"sync-date","scope":"organization","githubOrg":"your-org","date":"2026-01-15"}'
309+
# → {"action":"sync-date","result":{"success":true,"date":"2026-01-15","metricsCount":1}}
310+
```
311+
312+
**`sync-last-28`** — Download the latest 28-day report and store any new days. Most efficient for keeping the database current (1 API call for 28 days).
313+
314+
```bash
315+
curl -X POST http://localhost:3000/api/admin/sync \
316+
-H "Content-Type: application/json" \
317+
-d '{"action":"sync-last-28","scope":"organization","githubOrg":"your-org"}'
318+
# → {"action":"sync-last-28","success":true,"totalDays":28,"savedDays":27,"skippedDays":1,"errors":[]}
319+
```
320+
321+
**`sync-range`** — Download and store all days in a date range (one API call per day). Use for initial historical backfill.
322+
323+
```bash
324+
curl -X POST http://localhost:3000/api/admin/sync \
325+
-H "Content-Type: application/json" \
326+
-d '{"action":"sync-range","scope":"organization","githubOrg":"your-org","since":"2026-01-01","until":"2026-03-31"}'
327+
```
328+
329+
**`sync-gaps`** — Like `sync-range` but skips dates already present in the database. Uses bulk download for recent gaps and the 1-day endpoint for older gaps.
330+
331+
```bash
332+
curl -X POST http://localhost:3000/api/admin/sync \
333+
-H "Content-Type: application/json" \
334+
-d '{"action":"sync-gaps","scope":"organization","githubOrg":"your-org","since":"2026-01-01","until":"2026-04-20"}'
335+
# → {"action":"sync-gaps","gapsDetected":82,"gapsFilled":80,"outsideWindow":0,"failureCount":2,"results":[...]}
336+
```
337+
284338
## Environment Variables Reference
285339

286340
| Variable | Description | Required |

app/components/UserMetricsViewer.vue

Lines changed: 275 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,12 @@
200200
<tr>
201201
<td>
202202
<v-chip
203-
:color="getActivityColor(item.total_active_days)"
203+
:color="selectedUserLogin === item.login ? 'indigo' : getActivityColor(item.total_active_days)"
204+
:variant="selectedUserLogin === item.login ? 'elevated' : 'flat'"
204205
size="small"
205-
variant="flat"
206+
style="cursor: pointer;"
207+
:title="selectedUserLogin === item.login ? 'Click to clear filter' : 'Click to filter charts'"
208+
@click="toggleUserSelection(item.login)"
206209
>
207210
{{ item.login }}
208211
</v-chip>
@@ -269,6 +272,104 @@
269272
</v-container>
270273
</v-main>
271274

275+
<!-- User Drill-Down Charts -->
276+
<v-main class="p-1">
277+
<v-container :fluid="chartColumns === 'full'" :class="['elevation-2 mt-2 mb-2', chartColumns === 'full' ? 'px-0' : 'px-4']">
278+
<!-- Header row -->
279+
<div class="d-flex align-center justify-space-between flex-wrap gap-2 mb-3 pt-2">
280+
<div>
281+
<div class="text-subtitle-1 font-weight-medium">
282+
<v-icon size="small" class="mr-1">mdi-chart-bar</v-icon>
283+
{{ selectedUser ? selectedUser.login + ' — User Insights' : 'User Insights' }}
284+
</div>
285+
<div class="text-caption text-medium-emphasis">
286+
{{ selectedUser
287+
? 'Filtered to selected user. Click the badge again to clear.'
288+
: 'Click a user badge in the table to drill down into their usage.' }}
289+
</div>
290+
</div>
291+
<div class="d-flex align-center gap-2">
292+
<v-chip
293+
v-if="isUserHiddenByFilter"
294+
color="warning"
295+
size="small"
296+
closable
297+
@click:close="selectedUserLogin = null"
298+
>
299+
{{ selectedUserLogin }} (hidden by filter)
300+
</v-chip>
301+
<v-chip
302+
v-if="selectedUser"
303+
color="indigo"
304+
size="small"
305+
closable
306+
@click:close="selectedUserLogin = null"
307+
>
308+
{{ selectedUser.login }}
309+
</v-chip>
310+
</div>
311+
</div>
312+
313+
<v-row>
314+
<!-- 1. Language Distribution -->
315+
<v-col cols="12" :md="chartColumns === '2' ? 6 : 12">
316+
<v-card variant="outlined" class="pa-4">
317+
<div class="text-subtitle-2 font-weight-medium mb-1">Language Distribution</div>
318+
<div class="text-caption text-medium-emphasis mb-3">Code completions by language (top 10)</div>
319+
<div style="height: 240px;">
320+
<Doughnut v-if="langDistChartData.labels.length" :data="langDistChartData" :options="langDistOptions" />
321+
<div v-else class="d-flex align-center justify-center fill-height text-disabled text-caption">No language data available</div>
322+
</div>
323+
</v-card>
324+
</v-col>
325+
326+
<!-- 2. Feature Usage by Category -->
327+
<v-col cols="12" :md="chartColumns === '2' ? 6 : 12">
328+
<v-card variant="outlined" class="pa-4">
329+
<div class="text-subtitle-2 font-weight-medium mb-1">Feature Usage</div>
330+
<div class="text-caption text-medium-emphasis mb-3">Interactions by feature category</div>
331+
<div style="height: 240px;">
332+
<Bar :data="featureUsageChartData" :options="featureUsageOptions" />
333+
</div>
334+
</v-card>
335+
</v-col>
336+
337+
<!-- 3. Top Models by Interactions -->
338+
<v-col cols="12" :md="chartColumns === '2' ? 6 : 12">
339+
<v-card variant="outlined" class="pa-4">
340+
<div class="text-subtitle-2 font-weight-medium mb-1">Top Models by Interactions</div>
341+
<div class="text-caption text-medium-emphasis mb-3">Most used AI models</div>
342+
<div style="height: 240px;">
343+
<Bar v-if="topModelsChartData.labels.length" :data="topModelsChartData" :options="topModelsOptions" />
344+
<div v-else class="d-flex align-center justify-center fill-height text-disabled text-caption">No model data available</div>
345+
</div>
346+
</v-card>
347+
</v-col>
348+
349+
<!-- 4. Activity Over Time -->
350+
<v-col cols="12" :md="chartColumns === '2' ? 6 : 12">
351+
<v-card variant="outlined" class="pa-4">
352+
<div class="text-subtitle-2 font-weight-medium mb-1">Activity Over Time</div>
353+
<div class="text-caption text-medium-emphasis mb-3">
354+
{{ selectedUser ? selectedUser.login + '\'s stored snapshots' : 'Select a user to see their history' }}
355+
</div>
356+
<div style="height: 240px;" class="d-flex align-center justify-center">
357+
<v-progress-circular v-if="chartTrendLoading" indeterminate color="indigo" size="36" />
358+
<div v-else-if="!selectedUser" class="text-center text-disabled text-caption">
359+
<v-icon size="40" class="mb-2 d-block" style="opacity:0.3;">mdi-cursor-pointer</v-icon>
360+
Click a user badge to view their activity history
361+
</div>
362+
<div v-else-if="chartTrendData.length === 0" class="text-disabled text-caption">
363+
No historical snapshots for {{ selectedUser.login }}
364+
</div>
365+
<Line v-else style="width:100%;height:100%;" :data="chartTrendChartData" :options="chartTrendOptions" />
366+
</div>
367+
</v-card>
368+
</v-col>
369+
</v-row>
370+
</v-container>
371+
</v-main>
372+
272373
<!-- Per-user trend dialog -->
273374
<v-dialog v-model="trendDialog" max-width="760">
274375
<v-card>
@@ -324,7 +425,7 @@
324425
</template>
325426

326427
<script lang="ts">
327-
import { defineComponent, ref, computed, type PropType } from 'vue';
428+
import { defineComponent, ref, computed, watch, type PropType } from 'vue';
328429
import type { UserTotals } from '../../server/services/github-copilot-usage-api';
329430
import type { UserMetricsHistoryEntry, UserTimeSeriesEntry } from '../../server/storage/user-metrics-storage';
330431
import { CHAT_FEATURES, AGENT_FEATURES, COMPLETION_FEATURES, FEATURE_LABELS } from '../../shared/utils/feature-classification';
@@ -372,6 +473,17 @@ export default defineComponent({
372473
const search = ref('');
373474
const activityFilter = ref('all');
374475
476+
// ── User selection for drill-down charts ───────────────────────────────
477+
const selectedUserLogin = ref<string | null>(null);
478+
479+
const selectedUser = computed(() =>
480+
props.userMetrics.find(u => u.login === selectedUserLogin.value) ?? null
481+
);
482+
483+
function toggleUserSelection(login: string) {
484+
selectedUserLogin.value = selectedUserLogin.value === login ? null : login;
485+
}
486+
375487
// ── Per-user trend dialog ──────────────────────────────────────────────
376488
const trendDialog = ref(false);
377489
const trendLogin = ref('');
@@ -478,6 +590,150 @@ export default defineComponent({
478590
return result;
479591
});
480592
593+
// Source for drill-down charts: selected user or all filtered users
594+
const chartSource = computed(() =>
595+
selectedUser.value ? [selectedUser.value] : filteredUsers.value
596+
);
597+
598+
const isUserHiddenByFilter = computed(() =>
599+
selectedUserLogin.value !== null &&
600+
!filteredUsers.value.some(u => u.login === selectedUserLogin.value)
601+
);
602+
603+
// ── Drill-down Chart: Language Distribution ────────────────────────────
604+
const CHART_PALETTE = [
605+
'#4C8BF5', '#34A853', '#FBBC04', '#EA4335', '#AB47BC',
606+
'#00ACC1', '#FF7043', '#43A047', '#7E57C2', '#EC407A',
607+
];
608+
609+
const langDistChartData = computed(() => {
610+
const langMap = new Map<string, number>();
611+
for (const user of chartSource.value) {
612+
for (const entry of user.totals_by_language_feature || []) {
613+
langMap.set(entry.language, (langMap.get(entry.language) ?? 0) + entry.code_generation_activity_count);
614+
}
615+
}
616+
const sorted = [...langMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
617+
return {
618+
labels: sorted.map(([lang]) => lang),
619+
datasets: [{ data: sorted.map(([, v]) => v), backgroundColor: CHART_PALETTE, borderWidth: 1 }],
620+
};
621+
});
622+
623+
const langDistOptions = {
624+
responsive: true,
625+
maintainAspectRatio: false,
626+
plugins: { legend: { position: 'right' as const, labels: { boxWidth: 12, padding: 8, font: { size: 11 } } } },
627+
};
628+
629+
// ── Drill-down Chart: Feature Usage ────────────────────────────────────
630+
const featureUsageChartData = computed(() => {
631+
const featureMap = new Map<string, number>();
632+
for (const user of chartSource.value) {
633+
for (const entry of user.totals_by_feature || []) {
634+
featureMap.set(entry.feature, (featureMap.get(entry.feature) ?? 0) +
635+
entry.user_initiated_interaction_count + entry.code_generation_activity_count);
636+
}
637+
}
638+
const chatOnly = CHAT_FEATURES.filter(f => !AGENT_FEATURES.includes(f));
639+
const categories = [
640+
{ label: 'Completions', features: COMPLETION_FEATURES, color: 'rgba(54, 162, 235, 0.8)' },
641+
{ label: 'Chat', features: chatOnly, color: 'rgba(63, 81, 181, 0.8)' },
642+
{ label: 'Agent', features: AGENT_FEATURES, color: 'rgba(156, 39, 176, 0.8)' },
643+
];
644+
const values = categories.map(cat => cat.features.reduce((sum, f) => sum + (featureMap.get(f) ?? 0), 0));
645+
return {
646+
labels: categories.map(c => c.label),
647+
datasets: [{ label: 'Interactions', data: values, backgroundColor: categories.map(c => c.color), borderRadius: 4 }],
648+
};
649+
});
650+
651+
const featureUsageOptions = {
652+
responsive: true,
653+
maintainAspectRatio: false,
654+
indexAxis: 'y' as const,
655+
scales: { x: { beginAtZero: true }, y: { ticks: { font: { size: 12 } } } },
656+
plugins: { legend: { display: false } },
657+
};
658+
659+
// ── Drill-down Chart: Top Models ───────────────────────────────────────
660+
const topModelsChartData = computed(() => {
661+
const modelMap = new Map<string, number>();
662+
for (const user of chartSource.value) {
663+
for (const entry of user.totals_by_model_feature || []) {
664+
modelMap.set(entry.model, (modelMap.get(entry.model) ?? 0) + entry.user_initiated_interaction_count);
665+
}
666+
}
667+
const sorted = [...modelMap.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
668+
// Shorten long model names for readability
669+
const shortName = (m: string) => m.length > 30 ? m.slice(0, 28) + '' : m;
670+
return {
671+
labels: sorted.map(([m]) => shortName(m)),
672+
datasets: [{ label: 'Interactions', data: sorted.map(([, v]) => v), backgroundColor: 'rgba(255, 152, 0, 0.8)', borderRadius: 4 }],
673+
};
674+
});
675+
676+
const topModelsOptions = {
677+
responsive: true,
678+
maintainAspectRatio: false,
679+
indexAxis: 'y' as const,
680+
scales: { x: { beginAtZero: true }, y: { ticks: { font: { size: 11 } } } },
681+
plugins: { legend: { display: false } },
682+
};
683+
684+
// ── Drill-down Chart: Activity Over Time (selected user, historical) ───
685+
const chartTrendData = ref<UserTimeSeriesEntry[]>([]);
686+
const chartTrendLoading = ref(false);
687+
let chartTrendVersion = 0;
688+
689+
watch(selectedUser, async (user) => {
690+
if (!user || !showTrendButtons.value) {
691+
chartTrendData.value = [];
692+
return;
693+
}
694+
const version = ++chartTrendVersion;
695+
chartTrendLoading.value = true;
696+
try {
697+
const params = new URLSearchParams({ ...props.queryParams, login: user.login });
698+
const data = await $fetch<UserTimeSeriesEntry[]>(`/api/user-metrics-history?${params}`);
699+
if (version === chartTrendVersion) chartTrendData.value = data;
700+
} catch {
701+
if (version === chartTrendVersion) chartTrendData.value = [];
702+
} finally {
703+
if (version === chartTrendVersion) chartTrendLoading.value = false;
704+
}
705+
});
706+
707+
const chartTrendChartData = computed(() => ({
708+
labels: chartTrendData.value.map(e => e.report_end_day),
709+
datasets: [
710+
{
711+
label: 'Interactions',
712+
data: chartTrendData.value.map(e => e.user_initiated_interaction_count),
713+
borderColor: 'rgba(63, 81, 181, 1)',
714+
backgroundColor: 'rgba(63, 81, 181, 0.1)',
715+
fill: true, tension: 0.3, yAxisID: 'yCount',
716+
},
717+
{
718+
label: 'Acceptance Rate %',
719+
data: chartTrendData.value.map(e => e.acceptance_rate),
720+
borderColor: 'rgba(76, 175, 80, 1)',
721+
backgroundColor: 'rgba(76, 175, 80, 0.05)',
722+
fill: false, tension: 0.3, yAxisID: 'yRate',
723+
},
724+
],
725+
}));
726+
727+
const chartTrendOptions = {
728+
responsive: true,
729+
maintainAspectRatio: false,
730+
scales: {
731+
yCount: { type: 'linear' as const, position: 'left' as const, beginAtZero: true, title: { display: true, text: 'Interactions' } },
732+
yRate: { type: 'linear' as const, position: 'right' as const, beginAtZero: true, max: 120, title: { display: true, text: 'Rate %' }, grid: { drawOnChartArea: false } },
733+
},
734+
plugins: { legend: { position: 'bottom' as const } },
735+
};
736+
481737
function getAcceptanceRate(user: UserTotals): string {
482738
if (user.code_generation_activity_count === 0) return '0.0';
483739
return ((user.code_acceptance_activity_count / user.code_generation_activity_count) * 100).toFixed(1);
@@ -775,6 +1031,22 @@ export default defineComponent({
7751031
trendChartData,
7761032
trendChartOptions,
7771033
openUserTrend,
1034+
// user drill-down selection
1035+
selectedUserLogin,
1036+
selectedUser,
1037+
toggleUserSelection,
1038+
isUserHiddenByFilter,
1039+
// drill-down charts
1040+
langDistChartData,
1041+
langDistOptions,
1042+
featureUsageChartData,
1043+
featureUsageOptions,
1044+
topModelsChartData,
1045+
topModelsOptions,
1046+
chartTrendData,
1047+
chartTrendLoading,
1048+
chartTrendChartData,
1049+
chartTrendOptions,
7781050
};
7791051
},
7801052
data() {

e2e-tests/storage-pipeline.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ test.describe('Seed storage @seed', () => {
2323
test('bulk sync writes mock data to DB', async ({ request }) => {
2424
const syncResponse = await request.post('/api/admin/sync', {
2525
data: {
26-
action: 'sync-bulk',
26+
action: 'sync-last-28',
2727
scope: 'organization',
2828
githubOrg: ORG,
2929
isDataMocked: true,
@@ -32,7 +32,7 @@ test.describe('Seed storage @seed', () => {
3232

3333
expect(syncResponse.ok()).toBeTruthy();
3434
const syncResult = await syncResponse.json();
35-
expect(syncResult.action).toBe('sync-bulk');
35+
expect(syncResult.action).toBe('sync-last-28');
3636
expect(syncResult.success).toBe(true);
3737
expect(syncResult.totalDays).toBeGreaterThan(0);
3838

0 commit comments

Comments
 (0)