diff --git a/package-lock.json b/package-lock.json index 6c39f32..d9f34f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,11 @@ "@octokit/auth-app": "^7.1.4", "@octokit/rest": "^21.1.0", "@opentelemetry/api": "^1.9.0", + "chart.js": "^4.4.7", "next": "15.1.4", "pino": "^9.6.0", "react": "^19.0.0", + "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0" }, "devDependencies": { @@ -1790,6 +1792,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@navikt/aksel-icons": { "version": "7.9.1", "resolved": "https://registry.npmjs.org/@navikt/aksel-icons/-/aksel-icons-7.9.1.tgz", @@ -3563,6 +3571,18 @@ "node": ">=10" } }, + "node_modules/chart.js": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.7.tgz", + "integrity": "sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -8262,6 +8282,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz", + "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-dom": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", diff --git a/package.json b/package.json index 29cbd16..ead2849 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,11 @@ "@octokit/auth-app": "^7.1.4", "@octokit/rest": "^21.1.0", "@opentelemetry/api": "^1.9.0", + "chart.js": "^4.4.7", "next": "15.1.4", "pino": "^9.6.0", "react": "^19.0.0", + "react-chartjs-2": "^5.3.0", "react-dom": "^19.0.0" }, "devDependencies": { diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx new file mode 100644 index 0000000..92d9d4f --- /dev/null +++ b/src/app/usage/page.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { getCopilotUsage } from "@/lib/github"; +import UsageChart from "@/components/usage"; + +export default async function Usage() { + const { usage, error } = await getCopilotUsage("navikt"); + + + return ( +
+

Copilot Usage Stats

+ {error ? ( +

Error fetching usage data: {error}

+ ) : ( + <>
+ {usage && usage.length > 0 && ( +
+
+

{usage[usage.length - 1].total_active_users || 0}

+

Active Users

+
+
+

{usage[usage.length - 1].total_active_chat_users || 0}

+

Active Chat Users

+
+
+

+ {(() => { + const languageCount: Record = {}; + + usage[usage.length - 1].breakdown?.forEach((breakdownItem) => { + if (breakdownItem.language) { + if (!languageCount[breakdownItem.language]) { + languageCount[breakdownItem.language] = 0; + } + languageCount[breakdownItem.language] += breakdownItem.active_users || 0; + } + }); + + const topLanguage = Object.entries(languageCount).reduce( + (topLang, [language, users]) => users > topLang[1] ? [language, users] : topLang, + ['', 0] + )[0]; + + return topLanguage || 'N/A'; + })()} +

+

Top Language

+
+
+

+ {(() => { + const editorCount: Record = {}; + + usage[usage.length - 1].breakdown?.forEach((breakdownItem) => { + if (breakdownItem.editor) { + if (!editorCount[breakdownItem.editor]) { + editorCount[breakdownItem.editor] = 0; + } + editorCount[breakdownItem.editor] += breakdownItem.active_users || 0; + } + }); + + const topEditor = Object.entries(editorCount).reduce( + (topEd, [editor, users]) => users > topEd[1] ? [editor, users] : topEd, + ['', 0] + )[0]; + + return topEditor || 'N/A'; + })()} +

+

Top Editor

+
+
+ )} +
+ ) + } +
+ ); +}; diff --git a/src/components/usage.tsx b/src/components/usage.tsx new file mode 100644 index 0000000..7732c9a --- /dev/null +++ b/src/components/usage.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { CopilotUsage } from "@/lib/github"; +import React from "react"; +import { Line } from 'react-chartjs-2'; +import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend } from 'chart.js'; + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); + +interface UsageChartProps { + usage: CopilotUsage[]; +} + +const UsageChart: React.FC = ({ usage }) => { + const labels = usage ? usage.map((dayUsage) => dayUsage.day) : []; + const data = { + labels, + datasets: [ + { + label: 'Total Suggestions', + data: usage ? usage.map((dayUsage) => dayUsage.total_suggestions_count) : [], + borderColor: 'rgba(75, 192, 192, 1)', + backgroundColor: 'rgba(75, 192, 192, 0.2)', + }, + { + label: 'Total Acceptances', + data: usage ? usage.map((dayUsage) => dayUsage.total_acceptances_count) : [], + borderColor: 'rgba(153, 102, 255, 1)', + backgroundColor: 'rgba(153, 102, 255, 0.2)', + }, + { + label: 'Total Lines Suggested', + data: usage ? usage.map((dayUsage) => dayUsage.total_lines_suggested) : [], + borderColor: 'rgba(255, 159, 64, 1)', + backgroundColor: 'rgba(255, 159, 64, 0.2)', + }, + { + label: 'Total Lines Accepted', + data: usage ? usage.map((dayUsage) => dayUsage.total_lines_accepted) : [], + borderColor: 'rgba(54, 162, 235, 1)', + backgroundColor: 'rgba(54, 162, 235, 0.2)', + }, + { + label: 'Total Active Users', + data: usage ? usage.map((dayUsage) => dayUsage.total_active_users) : [], + borderColor: 'rgba(255, 206, 86, 1)', + backgroundColor: 'rgba(255, 206, 86, 0.2)', + }, + { + label: 'Total Chat Acceptances', + data: usage ? usage.map((dayUsage) => dayUsage.total_chat_acceptances) : [], + borderColor: 'rgba(75, 192, 192, 1)', + backgroundColor: 'rgba(75, 192, 192, 0.2)', + }, + { + label: 'Total Chat Turns', + data: usage ? usage.map((dayUsage) => dayUsage.total_chat_turns) : [], + borderColor: 'rgba(153, 102, 255, 1)', + backgroundColor: 'rgba(153, 102, 255, 0.2)', + }, + { + label: 'Total Active Chat Users', + data: usage ? usage.map((dayUsage) => dayUsage.total_active_chat_users) : [], + borderColor: 'rgba(54, 162, 235, 1)', + backgroundColor: 'rgba(54, 162, 235, 0.2)', + }, + ], + }; + + return ; +}; + +export default UsageChart; \ No newline at end of file diff --git a/src/lib/github.ts b/src/lib/github.ts index cf277ac..6232288 100644 --- a/src/lib/github.ts +++ b/src/lib/github.ts @@ -159,4 +159,38 @@ export async function unassignUserFromCopilot(org: string, username: string): Pr } catch (error) { return { seats_cancelled: null, error: (error instanceof Error ? error.message : String(error)) }; } -} \ No newline at end of file +} + +export type CopilotUsage = { + day: string; + total_suggestions_count?: number | undefined; + total_acceptances_count?: number | undefined; + total_lines_suggested?: number | undefined; + total_lines_accepted?: number | undefined; + total_active_users?: number | undefined; + total_chat_acceptances?: number | undefined; + total_chat_turns?: number | undefined; + total_active_chat_users?: number | undefined; + breakdown: Array<{ + language?: string | undefined; + editor?: string | undefined; + suggestions_count?: number | undefined; + acceptances_count?: number | undefined; + lines_suggested?: number | undefined; + lines_accepted?: number | undefined; + active_users?: number | undefined; + [key: string]: unknown; + }> | null +} + +export async function getCopilotUsage(org: string): Promise<{ usage: CopilotUsage[] | null, error: string | null }> { + try { + const { data } = await octokit.request('GET /orgs/{org}/copilot/usage', { + org + }); + + return { usage: data, error: null }; + } catch (error) { + return { usage: null, error: (error instanceof Error ? error.message : String(error)) }; + } +}