Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 37 additions & 6 deletions src/components/dialog/content/setting/CreditsPanel.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,52 @@
<template>
<div class="credits-container flex h-full flex-col gap-4">
<div>
<div
:class="
cn(
'credits-container flex flex-col gap-4',
embedded ? 'min-h-0 flex-1 overflow-y-auto' : 'h-full'
)
"
>
<div v-if="!embedded">
<h2 class="mb-2 text-2xl font-bold">
{{ $t('credits.credits') }}
</h2>
<Divider />
</div>

<CreditsTile />
<div v-if="embedded" class="rounded-2xl border border-interface-stroke p-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="m-0 text-base font-semibold">
{{ $t('credits.workspaceCredits') }}
</h3>
<Button
variant="secondary"
size="lg"
@click="handleCreditsHistoryClick"
>
{{ $t('subscription.manageBilling') }}
</Button>
</div>
<CreditsTile class="max-w-md" />
</div>
<CreditsTile v-else />

<div class="flex items-center justify-between">
<div v-if="!embedded" class="flex items-center justify-between">
<h3 class="m-0">{{ $t('credits.activity') }}</h3>
<Button variant="muted-textonly" @click="handleCreditsHistoryClick">
<i class="pi pi-arrow-up-right" />
{{ $t('credits.invoiceHistory') }}
</Button>
</div>

<UsageLogsTable ref="usageLogsTableRef" />
<UsageLogsTable v-if="!embedded" ref="usageLogsTableRef" />

<div class="flex flex-row gap-2">
<SubscriptionFooterLinks
v-if="embedded"
class="mt-auto"
:show-invoice-history="false"
/>
<div v-else class="flex flex-row gap-2">
<Button variant="muted-textonly" @click="handleFaqClick">
<i class="pi pi-question-circle" />
{{ $t('credits.faqs') }}
Expand All @@ -38,17 +65,21 @@

<script setup lang="ts">
import Divider from 'primevue/divider'
import { cn } from '@comfyorg/tailwind-utils'
import { ref, watch } from 'vue'

import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
import Button from '@/components/ui/button/Button.vue'
import SubscriptionFooterLinks from '@/platform/cloud/subscription/components/SubscriptionFooterLinks.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import { useTelemetry } from '@/platform/telemetry'
import { useAuthStore } from '@/stores/authStore'
import { useCommandStore } from '@/stores/commandStore'

const { embedded = false } = defineProps<{ embedded?: boolean }>()

const { buildDocsUrl, docsPaths } = useExternalLink()
const authStore = useAuthStore()
const authActions = useAuthActions()
Expand Down
26 changes: 25 additions & 1 deletion src/components/dialog/content/setting/UsageLogsTable.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<template>
<div>
<div
ref="rootContainer"
:class="cn(fitContainer && 'min-h-0 flex-1 overflow-y-auto')"
>
<div v-if="loading" class="flex items-center justify-center p-8">
<ProgressSpinner />
</div>
Expand Down Expand Up @@ -102,12 +105,16 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingRouting } from '@/composables/billing/useBillingRouting'
import { useTelemetry } from '@/platform/telemetry'
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import type { AuditLog } from '@/services/customerEventsService'
import {
EventType,
useCustomerEventsService
} from '@/services/customerEventsService'
import { cn } from '@comfyorg/tailwind-utils'

const { fitContainer = false } = defineProps<{ fitContainer?: boolean }>()

const { t } = useI18n()

Expand Down Expand Up @@ -218,6 +225,23 @@ watch(shouldUseWorkspaceBilling, () => {
})
})

const rootContainer = ref<HTMLElement | null>(null)
const { pageSize } = useAutoPageSize(
rootContainer,
7,
(container) =>
container.querySelector('.p-paginator')?.getBoundingClientRect().height ??
56
)
watch(pageSize, (size) => {
if (!fitContainer || size === pagination.value.limit) return
pagination.value.limit = size
pagination.value.page = 1
loadEvents().catch((error) => {
console.error('Error loading events:', error)
})
})

defineExpose({
refresh
})
Expand Down
1 change: 1 addition & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -2625,6 +2625,7 @@
},
"credits": {
"activity": "Activity",
"workspaceCredits": "Workspace credits",
"insufficient": {
"memberTitle": "This workspace is out of credits",
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
<template>
<div
class="flex items-center justify-between border-t border-interface-stroke pt-3"
>
<div class="flex items-center justify-between pt-3">
<div class="flex gap-2">
<Button
variant="muted-textonly"
Expand Down
103 changes: 53 additions & 50 deletions src/platform/settings/composables/useSettingUI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const CATEGORY_ICONS: Record<string, string> = {
'Mask Editor': 'icon-[lucide--pen-tool]',
Members: 'icon-[lucide--users]',
Other: 'icon-[lucide--ellipsis]',
'plan-credits': 'icon-[lucide--coins]',
PlanCredits: 'icon-[lucide--receipt-text]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
Expand Down Expand Up @@ -177,6 +178,21 @@ export function useSettingUI(
props: { section: 'planCredits' }
}

// Local has no workspace context, so it mounts the Plan & Credits tab shell
// directly instead of the workspace-header wrapper the cloud entry uses.
const localPlanCreditsPanel: SettingPanelItem = {
node: {
key: 'plan-credits',
label: 'Credits',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue')
),
props: { class: 'size-full' }
}

const membersPanel: SettingPanelItem = {
node: {
key: 'workspace-members',
Expand Down Expand Up @@ -273,6 +289,7 @@ export function useSettingUI(
creditsPanel,
userPanel,
...visibleWorkspacePanels.value,
...(!isCloud && isLoggedIn.value ? [localPlanCreditsPanel] : []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
Expand Down Expand Up @@ -312,24 +329,9 @@ export function useSettingUI(
)
})

// Cloud workspace sidebar structure
const workspaceMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Workspace settings
translateCategory({
key: 'workspace',
label: 'Workspace',
children: [
...visibleWorkspacePanels.value.map((panel) => panel.node),
// The legacy per-account Credits panel is redundant once the workspace
// Plan & Credits panel is present, which now owns the credit balance.
...(!billingControlsEnabled.value &&
isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])
].map(translateCategory)
}),
// General settings - Profile + all core settings + special panels
// Shared groups — both distributions render the same grammar:
// Workspace / General / Other. Only the Workspace entries differ.
const generalMenuGroup = computed<SettingTreeNode>(() =>
translateCategory({
key: 'general',
label: 'General',
Expand All @@ -345,51 +347,52 @@ export function useSettingUI(
translateCategory(aboutPanel.node),
...(isDesktop ? [translateCategory(serverConfigPanel.node)] : [])
]
}),
// Custom node settings (only shown if custom nodes have registered settings)
...(customNodeSettingCategories.value.length > 0
})
)

const otherMenuGroups = computed<SettingTreeNode[]>(() =>
customNodeSettingCategories.value.length > 0
? [
translateCategory({
key: 'other',
label: 'Other',
children: customNodeSettingCategories.value.map(translateCategory)
})
]
: [])
])
: []
)

// OSS legacy sidebar structure
const legacyMenuTreeNodes = computed<SettingTreeNode[]>(() => [
// Account settings - show different panels based on distribution and auth state
{
key: 'account',
label: 'Account',
const workspaceMenuTreeNodes = computed<SettingTreeNode[]>(() => [
translateCategory({
key: 'workspace',
label: 'Workspace',
children: [
userPanel.node,
...(shouldShowSecretsPanel.value ? [secretsPanel.node] : []),
...(isLoggedIn.value &&
...visibleWorkspacePanels.value.map((panel) => panel.node),
// The legacy per-account Credits panel is redundant once the workspace
// Plan & Credits panel is present, which now owns the credit balance.
...(!billingControlsEnabled.value &&
isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])
].map(translateCategory)
},
// Normal settings stored in the settingStore
{
key: 'settings',
label: 'Application Settings',
children: settingCategories.value.map(translateCategory)
},
// Special settings such as about, keybinding, extension, server-config
{
key: 'specialSettings',
label: 'Special Settings',
children: [
keybindingPanel.node,
extensionPanel.node,
aboutPanel.node,
...(isDesktop ? [serverConfigPanel.node] : [])
].map(translateCategory)
}
}),
generalMenuGroup.value,
...otherMenuGroups.value
])

const legacyMenuTreeNodes = computed<SettingTreeNode[]>(() => [
...(isLoggedIn.value
? [
translateCategory({
key: 'workspace',
label: 'Workspace',
children: [localPlanCreditsPanel.node].map(translateCategory)
})
]
: []),
generalMenuGroup.value,
...otherMenuGroups.value
])

const groupedMenuTreeNodes = computed<SettingTreeNode[]>(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,22 @@ import enMessages from '@/locales/en/main.json'

import PlanCreditsPanelContent from './PlanCreditsPanelContent.vue'

const mockDistribution = vi.hoisted(() => ({ cloud: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockDistribution.cloud
}
}))

const refreshSpy = vi.fn()

const stubs = {
SubscriptionPanelContentWorkspace: {
template: '<div data-testid="credits-body" />'
template: '<div data-testid="workspace-credits-body" />'
},
CreditsPanel: {
props: ['embedded'],
template: '<div data-testid="legacy-credits-body" />'
},
UsageLogsTable: {
template: '<div data-testid="usage-logs" />',
Expand All @@ -22,7 +33,8 @@ const stubs = {
}
}

function renderPanel() {
function renderPanel({ cloud = true } = {}) {
mockDistribution.cloud = cloud
const i18n = createI18n({
legacy: false,
locale: 'en',
Expand All @@ -32,20 +44,26 @@ function renderPanel() {
}

describe('PlanCreditsPanelContent', () => {
it('shows Credits and Activity tabs with Credits active by default', () => {
it('shows Credits and Activity tabs with the workspace overview by default on cloud', () => {
renderPanel()
expect(screen.getByRole('button', { name: 'Credits' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Activity' })).toBeTruthy()
expect(screen.getByTestId('credits-body')).toBeTruthy()
expect(screen.getByTestId('workspace-credits-body')).toBeTruthy()
expect(screen.queryByTestId('usage-logs')).toBeNull()
})

it('shows the embedded legacy credits body on local', () => {
renderPanel({ cloud: false })
expect(screen.getByTestId('legacy-credits-body')).toBeTruthy()
expect(screen.queryByTestId('workspace-credits-body')).toBeNull()
})

it('shows the usage-log table on the Activity tab and loads it', async () => {
refreshSpy.mockClear()
renderPanel()
await userEvent.click(screen.getByRole('button', { name: 'Activity' }))
expect(screen.getByTestId('usage-logs')).toBeTruthy()
expect(screen.queryByTestId('credits-body')).toBeNull()
expect(screen.queryByTestId('workspace-credits-body')).toBeNull()
await waitFor(() => expect(refreshSpy).toHaveBeenCalledTimes(1))
})

Expand Down
Loading
Loading