Skip to content

Commit 0befca8

Browse files
author
Lasim
committed
feat(frontend): add team usage statistics component and API integration
1 parent 0be749b commit 0befca8

File tree

7 files changed

+305
-24
lines changed

7 files changed

+305
-24
lines changed
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
<script setup lang="ts">
2+
import { ref, computed, onMounted, watch } from 'vue'
3+
import { useI18n } from 'vue-i18n'
4+
import { Button } from '@/components/ui/button'
5+
import { Progress } from '@/components/ui/progress'
6+
import { Alert, AlertDescription } from '@/components/ui/alert'
7+
import {
8+
Loader2,
9+
AlertTriangle,
10+
Server,
11+
RefreshCw,
12+
HardDrive,
13+
Globe
14+
} from 'lucide-vue-next'
15+
import { TeamService, type Team, type TeamUsageData } from '@/services/teamService'
16+
17+
const { t } = useI18n()
18+
19+
interface Props {
20+
team: Team
21+
}
22+
23+
const props = defineProps<Props>()
24+
25+
// State
26+
const usageData = ref<TeamUsageData | null>(null)
27+
const isLoading = ref(true)
28+
const error = ref<string | null>(null)
29+
30+
// Computed properties
31+
const totalMcpPercentage = computed(() => {
32+
if (!usageData.value) return 0
33+
const { total_installed_mcp_servers, limits } = usageData.value
34+
if (limits.mcp_server_limit === 0) return 0
35+
return Math.min(100, (total_installed_mcp_servers / limits.mcp_server_limit) * 100)
36+
})
37+
38+
const nonHttpMcpPercentage = computed(() => {
39+
if (!usageData.value) return 0
40+
const { non_http_mcp_servers, limits } = usageData.value
41+
if (limits.non_http_mcp_limit === 0) return 0
42+
return Math.min(100, (non_http_mcp_servers / limits.non_http_mcp_limit) * 100)
43+
})
44+
45+
const isAtTotalLimit = computed(() => {
46+
if (!usageData.value) return false
47+
return usageData.value.total_installed_mcp_servers >= usageData.value.limits.mcp_server_limit
48+
})
49+
50+
const isAtNonHttpLimit = computed(() => {
51+
if (!usageData.value) return false
52+
return usageData.value.non_http_mcp_servers >= usageData.value.limits.non_http_mcp_limit
53+
})
54+
55+
// Load usage data
56+
const loadUsageData = async () => {
57+
try {
58+
isLoading.value = true
59+
error.value = null
60+
usageData.value = await TeamService.getTeamUsage(props.team.id)
61+
} catch (err) {
62+
error.value = err instanceof Error ? err.message : 'Failed to load usage data'
63+
console.error('Error loading team usage:', err)
64+
} finally {
65+
isLoading.value = false
66+
}
67+
}
68+
69+
// Watch for team changes and reload data
70+
watch(() => props.team.id, () => {
71+
loadUsageData()
72+
}, { immediate: false })
73+
74+
onMounted(() => {
75+
loadUsageData()
76+
})
77+
</script>
78+
79+
<template>
80+
<div class="space-y-6">
81+
<!-- Loading State -->
82+
<div v-if="isLoading" class="flex items-center justify-center py-12">
83+
<div class="flex items-center gap-3 text-muted-foreground">
84+
<Loader2 class="h-5 w-5 animate-spin" />
85+
{{ t('teams.manage.usage.loading') }}
86+
</div>
87+
</div>
88+
89+
<!-- Error State -->
90+
<Alert v-else-if="error" variant="destructive">
91+
<AlertTriangle class="h-4 w-4" />
92+
<AlertDescription>
93+
{{ error }}
94+
</AlertDescription>
95+
<div class="mt-4">
96+
<Button
97+
variant="outline"
98+
size="sm"
99+
@click="loadUsageData"
100+
>
101+
<RefreshCw class="h-4 w-4 mr-2" />
102+
{{ t('teams.manage.usage.retry') }}
103+
</Button>
104+
</div>
105+
</Alert>
106+
107+
<!-- Usage Content -->
108+
<div v-else-if="usageData">
109+
<div class="px-4 sm:px-0">
110+
<h3 class="text-base/7 font-semibold text-gray-900">{{ t('teams.manage.usage.title') }}</h3>
111+
<p class="mt-1 max-w-2xl text-sm/6 text-gray-500">{{ t('teams.manage.usage.description') }}</p>
112+
</div>
113+
114+
<div class="mt-6 border-t border-gray-100">
115+
<dl class="divide-y divide-gray-100">
116+
<!-- Total MCP Servers -->
117+
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
118+
<dt class="text-sm/6 font-medium text-gray-900 flex items-center gap-2">
119+
<Server class="h-4 w-4 text-muted-foreground" />
120+
{{ t('teams.manage.usage.totalMcpServers') }}
121+
</dt>
122+
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0">
123+
<div class="space-y-2 max-w-md">
124+
<div class="flex justify-between text-sm">
125+
<span>{{ usageData.total_installed_mcp_servers }} / {{ usageData.limits.mcp_server_limit }}</span>
126+
<span :class="isAtTotalLimit ? 'text-destructive font-medium' : 'text-muted-foreground'">
127+
{{ Math.round(totalMcpPercentage) }}%
128+
</span>
129+
</div>
130+
<Progress
131+
:model-value="totalMcpPercentage"
132+
:class="isAtTotalLimit ? '[&>div]:bg-destructive' : ''"
133+
/>
134+
<p v-if="isAtTotalLimit" class="text-xs text-destructive">
135+
{{ t('teams.manage.usage.limitReached') }}
136+
</p>
137+
</div>
138+
</dd>
139+
</div>
140+
141+
<!-- Non-HTTP MCP Servers -->
142+
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
143+
<dt class="text-sm/6 font-medium text-gray-900 flex items-center gap-2">
144+
<HardDrive class="h-4 w-4 text-muted-foreground" />
145+
{{ t('teams.manage.usage.nonHttpMcpServers') }}
146+
</dt>
147+
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0">
148+
<div class="space-y-2 max-w-md">
149+
<div class="flex justify-between text-sm">
150+
<span>{{ usageData.non_http_mcp_servers }} / {{ usageData.limits.non_http_mcp_limit }}</span>
151+
<span :class="isAtNonHttpLimit ? 'text-destructive font-medium' : 'text-muted-foreground'">
152+
{{ Math.round(nonHttpMcpPercentage) }}%
153+
</span>
154+
</div>
155+
<Progress
156+
:model-value="nonHttpMcpPercentage"
157+
:class="isAtNonHttpLimit ? '[&>div]:bg-destructive' : ''"
158+
/>
159+
<p v-if="isAtNonHttpLimit" class="text-xs text-destructive">
160+
{{ t('teams.manage.usage.limitReached') }}
161+
</p>
162+
<p class="text-xs text-muted-foreground">
163+
{{ t('teams.manage.usage.nonHttpDescription') }}
164+
</p>
165+
</div>
166+
</dd>
167+
</div>
168+
169+
<!-- HTTP MCP Servers (info only) -->
170+
<div class="px-4 py-6 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
171+
<dt class="text-sm/6 font-medium text-gray-900 flex items-center gap-2">
172+
<Globe class="h-4 w-4 text-muted-foreground" />
173+
{{ t('teams.manage.usage.httpMcpServers') }}
174+
</dt>
175+
<dd class="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0">
176+
<div class="space-y-1">
177+
<span class="font-medium">{{ usageData.http_mcp_servers }}</span>
178+
<p class="text-xs text-muted-foreground">
179+
{{ t('teams.manage.usage.httpDescription') }}
180+
</p>
181+
</div>
182+
</dd>
183+
</div>
184+
</dl>
185+
</div>
186+
187+
<!-- Refresh Button -->
188+
<div class="flex items-center justify-end pt-4 border-t">
189+
<Button
190+
variant="outline"
191+
@click="loadUsageData"
192+
:disabled="isLoading"
193+
class="gap-2"
194+
>
195+
<RefreshCw class="h-4 w-4" :class="isLoading ? 'animate-spin' : ''" />
196+
{{ t('teams.manage.usage.refresh') }}
197+
</Button>
198+
</div>
199+
</div>
200+
</div>
201+
</template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { default as TeamInfo } from './TeamInfo.vue'
22
export { default as TeamMembers } from './TeamMembers.vue'
3+
export { default as TeamUsage } from './TeamUsage.vue'
34
export { default as TeamDangerZone } from './TeamDangerZone.vue'
Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,36 @@
11
<script setup lang="ts">
2-
import { computed } from 'vue'
2+
import type { ProgressRootProps } from "reka-ui"
3+
import type { HTMLAttributes } from "vue"
4+
import { reactiveOmit } from "@vueuse/core"
5+
import {
6+
ProgressIndicator,
7+
ProgressRoot,
8+
} from "reka-ui"
9+
import { cn } from "@/lib/utils"
310
4-
interface Props {
5-
modelValue: number
6-
max?: number
7-
}
11+
const props = withDefaults(
12+
defineProps<ProgressRootProps & { class?: HTMLAttributes["class"] }>(),
13+
{
14+
modelValue: 0,
15+
},
16+
)
817
9-
const props = withDefaults(defineProps<Props>(), {
10-
max: 100
11-
})
12-
13-
const percentage = computed(() => {
14-
return Math.min(Math.max(props.modelValue, 0), props.max)
15-
})
16-
17-
const progressPercentage = computed(() => {
18-
return (percentage.value / props.max) * 100
19-
})
18+
const delegatedProps = reactiveOmit(props, "class")
2019
</script>
2120

2221
<template>
23-
<div class="relative w-full overflow-hidden rounded-full bg-secondary h-2">
24-
<div
25-
class="h-full bg-primary transition-all duration-300 ease-in-out"
26-
:style="{ width: `${progressPercentage}%` }"
22+
<ProgressRoot
23+
v-bind="delegatedProps"
24+
:class="
25+
cn(
26+
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
27+
props.class,
28+
)
29+
"
30+
>
31+
<ProgressIndicator
32+
class="h-full w-full flex-1 bg-primary transition-all"
33+
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
2734
/>
28-
</div>
35+
</ProgressRoot>
2936
</template>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { default as Progress } from './Progress.vue'
1+
export { default as Progress } from "./Progress.vue"

services/frontend/src/i18n/locales/en/teams.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,19 @@ export default {
209209
}
210210
}
211211
},
212+
usage: {
213+
title: 'Resource Usage',
214+
description: 'Current MCP server usage and limits for this team.',
215+
loading: 'Loading usage data...',
216+
retry: 'Try Again',
217+
refresh: 'Refresh',
218+
totalMcpServers: 'Total MCP Servers',
219+
nonHttpMcpServers: 'Non-HTTP MCP Servers',
220+
httpMcpServers: 'HTTP MCP Servers',
221+
limitReached: 'You have reached the limit for this resource.',
222+
nonHttpDescription: 'Stdio transport servers that run as local processes on the satellite.',
223+
httpDescription: 'HTTP/SSE transport servers that connect to remote endpoints.'
224+
},
212225
fields: {
213226
name: {
214227
label: 'Team Name',

services/frontend/src/services/teamService.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ export type Team = z.infer<typeof TeamSchema>
3333
export type TeamWithRole = z.infer<typeof TeamWithRoleSchema>
3434
export type CreateTeamInput = z.infer<typeof CreateTeamSchema>
3535

36+
export interface TeamUsageLimits {
37+
mcp_server_limit: number;
38+
non_http_mcp_limit: number;
39+
}
40+
41+
export interface TeamUsageData {
42+
is_default_team: boolean;
43+
total_installed_mcp_servers: number;
44+
non_http_mcp_servers: number;
45+
http_mcp_servers: number;
46+
limits: TeamUsageLimits;
47+
}
48+
3649
export interface TeamResponse {
3750
success: boolean;
3851
teams: Team[]; // For /api/users/me/teams endpoint
@@ -453,4 +466,45 @@ export class TeamService {
453466
throw error
454467
}
455468
}
469+
470+
/**
471+
* Get team usage statistics
472+
*/
473+
static async getTeamUsage(teamId: string): Promise<TeamUsageData> {
474+
try {
475+
const apiUrl = this.getApiUrl()
476+
477+
const response = await fetch(`${apiUrl}/api/teams/${teamId}/usage`, {
478+
method: 'GET',
479+
headers: {
480+
'Content-Type': 'application/json',
481+
},
482+
credentials: 'include',
483+
})
484+
485+
if (!response.ok) {
486+
if (response.status === 401) {
487+
throw new Error('Unauthorized - please log in')
488+
}
489+
if (response.status === 403) {
490+
throw new Error('You do not have permission to view this team\'s usage')
491+
}
492+
if (response.status === 404) {
493+
throw new Error('Team not found')
494+
}
495+
throw new Error(`Failed to fetch team usage: ${response.status}`)
496+
}
497+
498+
const data = await response.json()
499+
500+
if (data.success && data.data) {
501+
return data.data
502+
} else {
503+
throw new Error('Invalid response format')
504+
}
505+
} catch (error) {
506+
console.error('Error fetching team usage:', error)
507+
throw error
508+
}
509+
}
456510
}

services/frontend/src/views/teams/manage/[id].vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert'
88
import { Loader2, AlertTriangle } from 'lucide-vue-next'
99
import { DsTabs, DsTabsItem } from '@/components/ui/ds-tabs'
1010
import DashboardLayout from '@/components/DashboardLayout.vue'
11-
import { TeamInfo, TeamMembers, TeamDangerZone } from '@/components/teams/manage'
11+
import { TeamInfo, TeamMembers, TeamUsage, TeamDangerZone } from '@/components/teams/manage'
1212
import { TeamService, type Team } from '@/services/teamService'
1313
import { useEventBus } from '@/composables/useEventBus'
1414
import { useBreadcrumbs } from '@/composables/useBreadcrumbs'
@@ -100,7 +100,7 @@ const handleTeamSelected = (data: { teamId: string; teamName: string }) => {
100100
// Initialize tab from query parameter
101101
const initializeTab = () => {
102102
const tabFromQuery = route.query.tab as string
103-
if (tabFromQuery && ['team-info', 'members', 'danger-zone'].includes(tabFromQuery)) {
103+
if (tabFromQuery && ['team-info', 'members', 'usage', 'danger-zone'].includes(tabFromQuery)) {
104104
activeTab.value = tabFromQuery
105105
}
106106
}
@@ -177,6 +177,7 @@ onUnmounted(() => {
177177
label="Members"
178178
:badge="memberCount > 1 ? memberCount : undefined"
179179
/>
180+
<DsTabsItem value="usage" label="Usage" />
180181
<DsTabsItem value="danger-zone" label="Danger Zone" />
181182
</DsTabs>
182183

@@ -193,6 +194,10 @@ onUnmounted(() => {
193194
:team="team"
194195
:can-manage-members="canManageMembers"
195196
/>
197+
<TeamUsage
198+
v-if="activeTab === 'usage'"
199+
:team="team"
200+
/>
196201
<TeamDangerZone
197202
v-if="activeTab === 'danger-zone'"
198203
:team="team"

0 commit comments

Comments
 (0)