diff --git a/ee/apps/den-web/app/(den)/_lib/den-org.ts b/ee/apps/den-web/app/(den)/_lib/den-org.ts index d4f18ef8e..a7e140935 100644 --- a/ee/apps/den-web/app/(den)/_lib/den-org.ts +++ b/ee/apps/den-web/app/(den)/_lib/den-org.ts @@ -315,6 +315,10 @@ export function getJoinOrgRoute(invitationId: string): string { return `/join-org?invite=${encodeURIComponent(invitationId)}`; } +export function getAnalyticsRoute(orgSlug?: string | null): string { + return `${getOrgDashboardRoute(orgSlug)}/analytics`; +} + export function getManageMembersRoute(orgSlug?: string | null): string { return `${getOrgDashboardRoute(orgSlug)}/manage-members`; } diff --git a/ee/apps/den-web/app/(den)/dashboard/(admin)/analytics/page.tsx b/ee/apps/den-web/app/(den)/dashboard/(admin)/analytics/page.tsx new file mode 100644 index 000000000..2823a8ee6 --- /dev/null +++ b/ee/apps/den-web/app/(den)/dashboard/(admin)/analytics/page.tsx @@ -0,0 +1,5 @@ +import { AnalyticsScreen } from "../../_components/analytics-screen"; + +export default function AnalyticsPage() { + return ; +} diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/analytics-screen.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/analytics-screen.tsx new file mode 100644 index 000000000..bac612119 --- /dev/null +++ b/ee/apps/den-web/app/(den)/dashboard/_components/analytics-screen.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { Activity, CheckCircle2, ChevronRight, Clock, Lock, Users, Zap } from "lucide-react"; +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { requestJson } from "../../_lib/den-flow"; +import { getBillingRoute, getMembersRoute } from "../../_lib/den-org"; +import { useOrgDashboard } from "../_providers/org-dashboard-provider"; + +/** Analytics is included for workspaces with at least this many seats. */ +const ANALYTICS_MIN_SEATS = 10; + +/* ── Types ── */ + +type AnalyticsWeek = { + weekStart: string; + activeMembers: number; + sessions: number; + tasksCompleted: number; + tasksFailed: number; +}; + +type AnalyticsData = { + members: number; + pendingInvites: number; + activeMembers7d: number; + activeMembers30d: number; + sessions7d: number; + sessions30d: number; + tasksCompleted7d: number; + tasksFailed7d: number; + tasksCompleted30d: number; + tasksFailed30d: number; + avgTaskDurationMs30d: number | null; + weekly: AnalyticsWeek[]; +}; + +/* ── Data ── */ + +function readNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function readWeek(value: unknown): AnalyticsWeek { + const w = (value && typeof value === "object" ? value : {}) as Record; + return { + weekStart: typeof w.weekStart === "string" ? w.weekStart : "", + activeMembers: readNumber(w.activeMembers), + sessions: readNumber(w.sessions), + tasksCompleted: readNumber(w.tasksCompleted), + tasksFailed: readNumber(w.tasksFailed), + }; +} + +async function fetchAnalytics(): Promise { + try { + const { response, payload } = await requestJson("/v1/telemetry/analytics", { method: "GET" }, 12000); + if (!response.ok || !payload || typeof payload !== "object") return null; + const p = payload as Record; + return { + members: readNumber(p.members), + pendingInvites: readNumber(p.pendingInvites), + activeMembers7d: readNumber(p.activeMembers7d), + activeMembers30d: readNumber(p.activeMembers30d), + sessions7d: readNumber(p.sessions7d), + sessions30d: readNumber(p.sessions30d), + tasksCompleted7d: readNumber(p.tasksCompleted7d), + tasksFailed7d: readNumber(p.tasksFailed7d), + tasksCompleted30d: readNumber(p.tasksCompleted30d), + tasksFailed30d: readNumber(p.tasksFailed30d), + avgTaskDurationMs30d: typeof p.avgTaskDurationMs30d === "number" ? p.avgTaskDurationMs30d : null, + weekly: Array.isArray(p.weekly) ? p.weekly.map(readWeek) : [], + }; + } catch { + return null; + } +} + +/* ── Helpers ── */ + +function formatDuration(ms: number | null): string { + if (ms === null) return "—"; + if (ms < 1000) return "<1s"; + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; +} + +function formatWeekLabel(weekStart: string): string { + const date = new Date(`${weekStart}T00:00:00Z`); + if (Number.isNaN(date.getTime())) return weekStart; + return date.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" }); +} + +function successRate(completed: number, failed: number): string { + const total = completed + failed; + if (total === 0) return "—"; + return `${Math.round((completed / total) * 100)}%`; +} + +function toneBg(tone: "violet" | "green" | "blue" | "amber") { + switch (tone) { + case "violet": return "bg-[#EDE4FF]"; + case "green": return "bg-[#E3F3E3]"; + case "blue": return "bg-[#E4ECFB]"; + case "amber": return "bg-[#FBF0DC]"; + } +} + +/* ── Small components ── */ + +function StatCard({ icon, title, value, sub, tone }: { + icon: React.ReactNode; title: string; value: string; sub?: string; tone: "violet" | "green" | "blue" | "amber"; +}) { + return ( +
+
+
{icon}
+
+
{title}
+
{value}
+ {sub ?
{sub}
: null} +
+
+
+ ); +} + +type BarSeries = { + label: string; + color: string; + values: number[]; +}; + +function TrendChart({ title, subtitle, weeks, series }: { + title: string; + subtitle: string; + weeks: AnalyticsWeek[]; + series: BarSeries[]; +}) { + const max = Math.max(1, ...series.flatMap((s) => s.values)); + const hasData = series.some((s) => s.values.some((v) => v > 0)); + + return ( +
+
+
+

{title}

+

{subtitle}

+
+ {series.length > 1 ? ( +
+ {series.map((s) => ( + + + {s.label} + + ))} +
+ ) : null} +
+ +
+
+ {weeks.map((week, i) => ( +
+ {series.map((s) => { + const value = s.values[i] ?? 0; + const height = value > 0 ? Math.max(4, (value / max) * 100) : 2; + return ( +
0 ? s.color : "#EBEEF4", + }} + /> + ); + })} +
+ ))} +
+ {!hasData ? ( +
+ No usage events yet +
+ ) : null} +
+ +
+ {weeks.length > 0 ? formatWeekLabel(weeks[0].weekStart) : ""} + {weeks.length > 0 ? formatWeekLabel(weeks[weeks.length - 1].weekStart) : ""} +
+
+ ); +} + +/* ── Main screen ── */ + +export function AnalyticsScreen() { + const { activeOrg, orgContext } = useOrgDashboard(); + + const { data, isLoading } = useQuery({ + queryKey: ["telemetry", "analytics"], + queryFn: fetchAnalytics, + }); + + const weekly = data?.weekly ?? []; + const tasks7d = (data?.tasksCompleted7d ?? 0) + (data?.tasksFailed7d ?? 0); + const seats = orgContext?.members.length ?? data?.members ?? 0; + const locked = Boolean(orgContext) && seats < ANALYTICS_MIN_SEATS; + + return ( +
+ + {/* Breadcrumb */} +
+ {activeOrg?.name ?? "OpenWork Cloud"} + + Analytics +
+ + {/* Header */} +
+

Usage & adoption

+ + Included with {ANALYTICS_MIN_SEATS}+ seats + +
+

+ See how your team is adopting OpenWork — active members, sessions, and task activity over time. + Only event metadata is collected — never prompts, code, or file contents. +

+ + {locked ? ( +
+
+
+ +
+

+ Analytics is available for workspaces with {ANALYTICS_MIN_SEATS} or more seats +

+
+

+ Your workspace currently has {seats} {seats === 1 ? "seat" : "seats"}. Add seats to unlock + adoption and usage insights — active members, session frequency, and task activity across your whole team. +

+
+ + Invite members + + + Manage seats & billing + +
+
+ ) : ( + <> + {/* Summary cards */} +
+ } + title="OpenWork users" + value={isLoading ? "…" : `${data?.members ?? 0}`} + sub={`${data?.pendingInvites ?? 0} pending invites`} + tone="violet" + /> + } + title="Active this week" + value={isLoading ? "…" : `${data?.activeMembers7d ?? 0}`} + sub={`${data?.activeMembers30d ?? 0} active in last 30 days`} + tone="blue" + /> + } + title="Sessions this week" + value={isLoading ? "…" : `${data?.sessions7d ?? 0}`} + sub={`${data?.sessions30d ?? 0} in last 30 days`} + tone="amber" + /> + } + title="Tasks this week" + value={isLoading ? "…" : `${tasks7d}`} + sub={`${successRate(data?.tasksCompleted7d ?? 0, data?.tasksFailed7d ?? 0)} success rate`} + tone="green" + /> +
+ + {/* Trend charts */} +
+ w.activeMembers) }]} + /> + w.sessions) }]} + /> +
+ +
+ w.tasksCompleted) }, + { label: "Failed", color: "#E5484D", values: weekly.map((w) => w.tasksFailed) }, + ]} + /> +
+ + {/* 30-day detail */} +
+ } + title="Avg task duration" + value={isLoading ? "…" : formatDuration(data?.avgTaskDurationMs30d ?? null)} + sub="Completed tasks, last 30 days" + tone="blue" + /> + } + title="Tasks completed" + value={isLoading ? "…" : `${data?.tasksCompleted30d ?? 0}`} + sub="Last 30 days" + tone="green" + /> + } + title="Tasks failed" + value={isLoading ? "…" : `${data?.tasksFailed30d ?? 0}`} + sub={`${successRate(data?.tasksCompleted30d ?? 0, data?.tasksFailed30d ?? 0)} success rate over 30 days`} + tone="amber" + /> +
+ + {/* Privacy note */} +

+ Telemetry never includes prompt contents, code, file contents, diffs, secrets, or terminal output. + Usage data appears here once members sign in to the OpenWork app and start running tasks. +

+ + )} +
+ ); +} diff --git a/ee/apps/den-web/app/(den)/dashboard/_components/org-dashboard-shell.tsx b/ee/apps/den-web/app/(den)/dashboard/_components/org-dashboard-shell.tsx index 24eb8d8f5..2c9b12f14 100644 --- a/ee/apps/den-web/app/(den)/dashboard/_components/org-dashboard-shell.tsx +++ b/ee/apps/den-web/app/(den)/dashboard/_components/org-dashboard-shell.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useMemo, useState } from "react"; import { + BarChart3, BookOpen, Bot, Cable, @@ -27,6 +28,7 @@ import { import { useDenFlow } from "../../_providers/den-flow-provider"; import { formatRoleLabel, + getAnalyticsRoute, getBackgroundAgentsRoute, getApiKeysRoute, getBillingRoute, @@ -109,6 +111,9 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) { if (pathname === dashboardRoot) { return "Home"; } + if (pathname.startsWith(getAnalyticsRoute(orgSlug))) { + return "Analytics"; + } if (pathname.startsWith(getMembersRoute(orgSlug))) { return "Members"; } @@ -187,6 +192,12 @@ export function OrgDashboardShell({ children }: { children: React.ReactNode }) { }, ...(access.isAdmin ? [ + { + href: activeOrg ? getAnalyticsRoute(activeOrg.slug) : "#", + label: "Analytics", + icon: BarChart3, + badge: "New", + }, // NOTE: Shared Workspace soft-disabled — uncomment to re-enable // { // href: activeOrg ? getBackgroundAgentsRoute(activeOrg.slug) : "#",