Compare commits

..

2 Commits

Author SHA1 Message Date
comfydesigner
b0c5775f49 feat: workspace activity ledger + member auditing
Adds the Activity tab to Plan & Credits: a sortable, paginated credit
ledger (auto page-sized to the dialog) with per-user hover-card rollups
and partner-node detail on usage rows; members are scoped to their own
rows, owners and admins see everyone. The Credits-tab snapshot's Recent
activity now deep-links here, and the tab-row search scopes the ledger.
There is no usage-activity endpoint yet — the ledger renders its empty
state until events arrive; the filter/sort/pagination/rollup machinery
is real and ready for the data.
2026-07-10 15:11:30 -07:00
comfydesigner
f5c5597d70 feat: credit auto-reload with a monthly budget
Adds the auto-reload section to the Credits tab: threshold + reload
amount configured through a dialog (with a minimum-amount guard), an
optional monthly budget rendered as a color-coded progress bar (muted,
amber near the cap, red and Paused-badged when exhausted), and a
toggle-first enable sentence. The whole section freezes — dimmed,
forced off — whenever the plan can't spend (paused or lapsed), and
card failures are covered by the shared payment-declined banner rather
than tile-level state. Config is client-side until the auto-reload
endpoint exists; it starts unconfigured and edits don't persist.
2026-07-10 15:10:40 -07:00
15 changed files with 1287 additions and 25 deletions

View File

@@ -54,11 +54,6 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Pending integration in the workspace-settings stacked PRs: consumed by
// split/auto-reload + split/allowlist (Switch) and split/member-auditing
// + split/allowlist (Pagination); each consumer removes its entry
'src/components/ui/switch/Switch.vue',
'src/components/ui/pagination/Pagination.vue',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui'
import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui'
import { provide, ref } from 'vue'
import { hoverCardOpenKey } from './hoverCardContext'
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
const props = defineProps<HoverCardRootProps>()
const emits = defineEmits<HoverCardRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
const isOpen = ref(false)
provide(hoverCardOpenKey, isOpen)
</script>
<template>
<HoverCardRoot v-bind="forwarded" v-model:open="isOpen">
<slot />
</HoverCardRoot>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import { HoverCardContent, HoverCardPortal, useForwardProps } from 'reka-ui'
import type { HoverCardContentProps } from 'reka-ui'
import { computed, inject } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { hoverCardOpenKey } from './hoverCardContext'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
const {
class: className,
side = 'bottom',
sideOffset = 8,
...rest
} = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
const forwarded = useForwardProps(computed(() => rest))
// Body-portaled content sits at a static z-1700 unless a dialog that joined
// @primeuix's 'modal' counter is open above it; then lift past that dialog.
const open = inject(hoverCardOpenKey, undefined)
const contentStyle = computed(() => {
if (!open?.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
</script>
<template>
<HoverCardPortal>
<HoverCardContent
v-bind="forwarded"
:side
:side-offset
:style="contentStyle"
:class="
cn(
'z-1700 rounded-lg border border-border-subtle bg-secondary-background p-2.5 shadow-md outline-none',
className
)
"
>
<slot />
</HoverCardContent>
</HoverCardPortal>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { HoverCardTrigger } from 'reka-ui'
import type { HoverCardTriggerProps } from 'reka-ui'
const props = defineProps<HoverCardTriggerProps>()
</script>
<template>
<HoverCardTrigger v-bind="props">
<slot />
</HoverCardTrigger>
</template>

View File

@@ -0,0 +1,7 @@
import type { InjectionKey, Ref } from 'vue'
// Shares the root open-state with the content so it can lift its z-index above
// a dialog that joined @primeuix's incrementing 'modal' counter (otherwise the
// body-portaled content renders behind the settings dialog).
export const hoverCardOpenKey: InjectionKey<Ref<boolean>> =
Symbol('hoverCardOpen')

View File

@@ -3068,8 +3068,62 @@
"planCredits": {
"tabs": {
"invoices": "Invoices",
"overview": "Credits"
"overview": "Credits",
"activity": "Activity"
}
},
"autoReload": {
"badge": {
"off": "Off",
"paused": "Paused"
},
"dialog": {
"allowsReloads": "Allows {count} reload /mo | Allows {count} reloads /mo",
"amountLabel": "Add this amount of credits:",
"budgetPlaceholderCredits": "Enter an amount of credits",
"budgetPlaceholderUsd": "Enter an amount of dollars",
"budgetToggleHint": "Limit how much is auto-reloaded per month",
"budgetToggleLabel": "Monthly budget",
"cancel": "Cancel",
"minReload": "Minimum amount is {amount}",
"thresholdLabel": "When credits drop below:",
"title": "Auto-reload credits",
"update": "Update"
},
"disabled": "Disabled",
"edit": "Edit",
"empty": {
"body": "Keep your workflows running with auto-reloaded credits. Set a monthly budget so charges don't surprise you.",
"cta": "Set up auto-reload"
},
"enabled": "Enabled",
"subtitle": "Automatically add credits when your balance runs low, within an optional monthly budget.",
"tile": {
"label": "Auto-reload",
"monthlyBudget": "Monthly budget",
"percentSpent": "{percent}% spent",
"spentOfBudget": "{spent} of {budget}",
"whenBelow": "when credits drop below"
},
"title": "Credit auto-reload"
},
"activity": {
"columns": {
"creditsUsed": "Credits",
"date": "Date",
"eventDetails": "Event details",
"eventType": "Event type",
"user": "User"
},
"fullActivity": "Full activity",
"hoverCard": {
"lastActivity": "Last activity",
"partnerNodeUsed": "Partner node used",
"totalCreditsUsed": "Total credits used"
},
"perUserHint": "Looking for total usage per user?",
"seeMembers": "See Members",
"empty": "No activity yet."
}
},
"teamWorkspacesDialog": {

View File

@@ -0,0 +1,313 @@
<template>
<div
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.autoReload.dialog.title') }}
</h2>
<button
class="cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground"
:aria-label="$t('g.close')"
@click="onClose"
>
<i class="pi pi-times size-4" />
</button>
</div>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-2">
<label
for="auto-reload-threshold"
class="text-sm text-muted-foreground"
>
{{ $t('workspacePanel.autoReload.dialog.thresholdLabel') }}
</label>
<div :class="fieldClass">
<i class="icon-[lucide--coins] size-4 shrink-0 text-credit" />
<input
id="auto-reload-threshold"
v-model="thresholdModel"
inputmode="numeric"
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="auto-reload-amount" class="text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.dialog.amountLabel') }}
</label>
<div
:class="cn(fieldClass, reloadBelowMinimum && 'ring-1 ring-red-500')"
>
<i
v-if="unit === 'credits'"
class="icon-[lucide--coins] size-4 shrink-0 text-credit"
/>
<span v-else class="shrink-0 text-sm text-muted-foreground">$</span>
<input
id="auto-reload-amount"
v-model="reloadModel"
inputmode="numeric"
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none"
/>
<span
class="flex shrink-0 items-center gap-1 text-sm text-muted-foreground tabular-nums"
>
<template v-if="unit === 'credits'"
> {{ reloadCostLabel }}</template
>
<template v-else>
<i class="icon-[lucide--coins] size-3.5 text-muted-foreground" />
{{ reloadCreditsLabel }}
</template>
</span>
</div>
<p v-if="reloadError" class="m-0 text-xs text-red-500">
{{ reloadError }}
</p>
</div>
</div>
<div class="flex flex-col gap-2 border-t border-border-default p-4">
<div class="flex items-center justify-between">
<span
id="auto-reload-budget-label"
class="text-sm font-medium text-base-foreground"
>
{{ $t('workspacePanel.autoReload.dialog.budgetToggleLabel') }}
</span>
<span class="flex items-center gap-2 text-sm text-muted-foreground">
{{
budgetEnabled
? $t('workspacePanel.autoReload.enabled')
: $t('workspacePanel.autoReload.disabled')
}}
<Switch v-model="budgetEnabled" />
</span>
</div>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.dialog.budgetToggleHint') }}
</p>
<div :class="cn(fieldClass, !budgetEnabled && 'opacity-50')">
<i
v-if="unit === 'credits'"
class="icon-[lucide--coins] size-4 shrink-0 text-credit"
/>
<span v-else class="shrink-0 text-sm text-muted-foreground">$</span>
<input
v-model="budgetModel"
:disabled="!budgetEnabled"
aria-labelledby="auto-reload-budget-label"
inputmode="numeric"
:placeholder="budgetPlaceholder"
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none disabled:cursor-not-allowed"
/>
<span
v-if="budgetEnabled && budgetCents > 0"
class="flex shrink-0 items-center gap-1 text-sm text-muted-foreground tabular-nums"
>
<template v-if="unit === 'credits'"> {{ budgetUsdLabel }}</template>
<template v-else>
<i class="icon-[lucide--coins] size-3.5 text-muted-foreground" />
{{ budgetCreditsLabel }}
</template>
</span>
</div>
<p
v-if="budgetEnabled && budgetCents > 0"
class="m-0 text-xs text-muted-foreground"
>
{{ allowsReloadsLabel }}
</p>
</div>
<div
class="flex items-center justify-between border-t border-border-default p-4"
>
<ToggleGroup
type="single"
:model-value="unit"
class="rounded-lg bg-secondary-background p-0.5"
@update:model-value="onUnitChange"
>
<ToggleGroupItem
v-for="option in unitOptions"
:key="option"
:value="option"
size="lg"
>
{{ $t(`workspacePanel.autoReload.dialog.${option}`) }}
</ToggleGroupItem>
</ToggleGroup>
<div class="flex items-center gap-4">
<Button variant="muted-textonly" @click="onClose">
{{ $t('workspacePanel.autoReload.dialog.cancel') }}
</Button>
<Button
variant="secondary"
size="lg"
:disabled="!canUpdate"
@click="onUpdate"
>
{{ $t('workspacePanel.autoReload.dialog.update') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
centsToCredits,
creditsToCents,
creditsToUsd,
usdToCents,
usdToCredits
} from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import Switch from '@/components/ui/switch/Switch.vue'
import ToggleGroup from '@/components/ui/toggle-group/ToggleGroup.vue'
import ToggleGroupItem from '@/components/ui/toggle-group/ToggleGroupItem.vue'
import { useAutoReload } from '@/platform/workspace/composables/useAutoReload'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@comfyorg/tailwind-utils'
const { t, n: fmtNumber } = useI18n()
const dialogStore = useDialogStore()
const { config, save } = useAutoReload()
type Unit = 'credits' | 'usd'
const unitOptions: Unit[] = ['credits', 'usd']
const unit = ref<Unit>('credits')
// reka's single ToggleGroup can emit '' on re-click (deselect); ignore that so a
// unit always stays selected.
function onUnitChange(value: unknown) {
if (value === 'credits' || value === 'usd') unit.value = value
}
const thresholdCredits = ref(config.thresholdCredits ?? 1000)
const reloadCredits = ref(config.reloadCredits ?? 5000)
const budgetEnabled = ref(config.monthlyBudgetCents != null)
const budgetCents = ref(config.monthlyBudgetCents ?? 0)
const fieldClass =
'flex items-center gap-2 rounded-lg bg-secondary-background px-3 py-2.5'
const fmtInt = (value: number) => fmtNumber(value, { maximumFractionDigits: 0 })
const fmtUsd = (cents: number) =>
fmtNumber(cents / 100, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
})
const parseNum = (raw: string) => {
const parsed = Number(raw.replace(/[^0-9.]/g, ''))
return Number.isFinite(parsed) ? parsed : 0
}
// A 0 value renders as an empty field (not "0") so backspacing clears it.
const thresholdModel = computed({
get: () =>
thresholdCredits.value === 0 ? '' : fmtInt(thresholdCredits.value),
set: (value) => (thresholdCredits.value = Math.round(parseNum(value)))
})
const reloadModel = computed({
get: () => {
if (reloadCredits.value === 0) return ''
return unit.value === 'credits'
? fmtInt(reloadCredits.value)
: fmtInt(creditsToUsd(reloadCredits.value))
},
set: (value) => {
const parsed = parseNum(value)
reloadCredits.value =
unit.value === 'credits' ? Math.round(parsed) : usdToCredits(parsed)
}
})
const reloadCostLabel = computed(() =>
fmtUsd(creditsToCents(reloadCredits.value))
)
const reloadCreditsLabel = computed(() => fmtInt(reloadCredits.value))
const budgetModel = computed({
get: () => {
if (budgetCents.value === 0) return ''
return unit.value === 'credits'
? fmtInt(centsToCredits(budgetCents.value))
: fmtInt(Math.round(budgetCents.value / 100))
},
set: (value) => {
const parsed = parseNum(value)
budgetCents.value =
unit.value === 'credits'
? creditsToCents(Math.round(parsed))
: usdToCents(parsed)
}
})
const budgetUsdLabel = computed(() => fmtUsd(budgetCents.value))
const budgetCreditsLabel = computed(() =>
fmtInt(centsToCredits(budgetCents.value))
)
const budgetPlaceholder = computed(() =>
unit.value === 'credits'
? t('workspacePanel.autoReload.dialog.budgetPlaceholderCredits')
: t('workspacePanel.autoReload.dialog.budgetPlaceholderUsd')
)
const allowsReloadsLabel = computed(() => {
const reloads =
reloadCredits.value > 0
? Math.floor(centsToCredits(budgetCents.value) / reloadCredits.value)
: 0
return t('workspacePanel.autoReload.dialog.allowsReloads', reloads)
})
// The reload amount must be worth at least $5 (its credit equivalent).
const MIN_RELOAD_CENTS = 500
const MIN_RELOAD_CREDITS = usdToCredits(5)
const reloadBelowMinimum = computed(
() => reloadCredits.value > 0 && reloadCredits.value < MIN_RELOAD_CREDITS
)
const reloadError = computed(() => {
if (!reloadBelowMinimum.value) return ''
const amount =
unit.value === 'credits'
? fmtInt(MIN_RELOAD_CREDITS)
: fmtUsd(MIN_RELOAD_CENTS)
return t('workspacePanel.autoReload.dialog.minReload', { amount })
})
const canUpdate = computed(
() =>
thresholdCredits.value > 0 &&
reloadCredits.value >= MIN_RELOAD_CREDITS &&
(!budgetEnabled.value || budgetCents.value > 0)
)
function onClose() {
dialogStore.closeDialog({ key: 'auto-reload' })
}
function onUpdate() {
if (!canUpdate.value) return
save({
thresholdCredits: thresholdCredits.value,
reloadCredits: reloadCredits.value,
monthlyBudgetCents:
budgetEnabled.value && budgetCents.value > 0 ? budgetCents.value : null
})
onClose()
}
</script>

View File

@@ -0,0 +1,205 @@
<template>
<div
:class="
cn(
'flex flex-col gap-4 rounded-2xl border border-interface-stroke/60 p-6 transition-opacity',
// A lapsed plan can't auto-reload, so freeze the whole section: dim it,
// block interaction, and force the toggle to read Disabled.
frozen && 'pointer-events-none opacity-50'
)
"
>
<div
class="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between"
>
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-base-foreground">
{{ $t('workspacePanel.autoReload.title') }}
</span>
<span class="max-w-md text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.subtitle') }}
</span>
</div>
<div v-if="isConfigured" class="flex shrink-0 items-center gap-3">
<span class="flex items-center gap-2 text-sm text-muted-foreground">
{{ enabledLabel }}
<Switch
:model-value="displayEnabled"
@update:model-value="setEnabled"
/>
</span>
<Button variant="secondary" size="lg" @click="openConfig">
{{ $t('workspacePanel.autoReload.edit') }}
</Button>
</div>
</div>
<!-- Empty / not-set-up state same one-column grid as the configured tile
so both sit at the top tiles' width. -->
<div v-if="!isConfigured" class="grid grid-cols-1 gap-4 @4xl:grid-cols-2">
<div
class="flex flex-col gap-3 rounded-xl bg-modal-panel-background px-6 py-5"
>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.empty.body') }}
</p>
<Button variant="tertiary" size="lg" class="w-full" @click="openConfig">
{{ $t('workspacePanel.autoReload.empty.cta') }}
</Button>
</div>
</div>
<!-- Configured tile constrained to one column of the same grid the top
tiles use, so it matches their width (empty second column left open). -->
<div v-else class="grid grid-cols-1 gap-4 @4xl:grid-cols-2">
<div
:class="
cn(
'flex flex-col gap-4 rounded-xl bg-modal-panel-background px-6 py-5 transition-opacity',
// Skip this dim when frozen — the section root already dims uniformly.
!frozen && !isEnabled && 'opacity-50'
)
"
>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.tile.label') }}
</span>
<StatusBadge v-if="badge" :label="badge" :severity="badgeSeverity" />
</div>
<p
:class="
cn(
'm-0 flex items-center gap-1.5 text-sm text-muted-foreground',
isPaused && 'opacity-50'
)
"
>
<i class="icon-[lucide--coins] size-4 text-credit" />
<span
class="text-2xl leading-none font-semibold text-base-foreground tabular-nums"
>
{{ reloadCreditsLabel }}
</span>
{{ $t('workspacePanel.autoReload.tile.whenBelow') }}
<span class="font-semibold text-base-foreground tabular-nums">
{{ thresholdLabel }}
</span>
</p>
<template v-if="hasBudget">
<div class="h-px w-full bg-interface-stroke" />
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">
{{ $t('workspacePanel.autoReload.tile.monthlyBudget') }}
</span>
<span :class="cn('tabular-nums', percentSpentClass)">
{{ percentSpentLabel }}
</span>
</div>
<ProgressBar :value="budgetUsedFraction" />
<div class="flex justify-end text-sm">
<span class="text-muted-foreground tabular-nums">
{{ budgetSpentLabel }}
</span>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import Switch from '@/components/ui/switch/Switch.vue'
import ProgressBar from '@/platform/workspace/components/dialogs/settings/ProgressBar.vue'
import { useAutoReload } from '@/platform/workspace/composables/useAutoReload'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@comfyorg/tailwind-utils'
const { frozen = false } = defineProps<{
/**
* The plan can't spend (lapsed or paused): render the whole section dimmed,
* off, and non-interactive.
*/
frozen?: boolean
}>()
const { t, n: fmtNumber } = useI18n()
const {
config,
isConfigured,
isEnabled,
hasBudget,
budgetUsedFraction,
isPaused,
isWarning,
setEnabled
} = useAutoReload()
const displayEnabled = computed(() => !frozen && isEnabled.value)
const { showAutoReloadDialog } = useDialogService()
function openConfig() {
void showAutoReloadDialog()
}
const fmtCredits = (value: number) =>
fmtNumber(value, { maximumFractionDigits: 0 })
const fmtUsd = (cents: number) =>
fmtNumber(cents / 100, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
})
const enabledLabel = computed(() =>
displayEnabled.value
? t('workspacePanel.autoReload.enabled')
: t('workspacePanel.autoReload.disabled')
)
const badge = computed(() => {
if (frozen) return t('workspacePanel.autoReload.badge.off')
if (isPaused.value) return t('workspacePanel.autoReload.badge.paused')
if (!isEnabled.value) return t('workspacePanel.autoReload.badge.off')
return ''
})
// Paused is an alert state, so give it the high-contrast (inverted) pill; the
// quieter "off" state keeps the secondary treatment.
const badgeSeverity = computed(() =>
isPaused.value ? 'contrast' : 'secondary'
)
const reloadCreditsLabel = computed(() => fmtCredits(config.reloadCredits))
const thresholdLabel = computed(() => fmtCredits(config.thresholdCredits))
const percentSpentLabel = computed(() =>
t('workspacePanel.autoReload.tile.percentSpent', {
percent: Math.round(budgetUsedFraction.value * 100)
})
)
const percentSpentClass = computed(() =>
isPaused.value
? 'text-danger'
: isWarning.value
? 'text-credit'
: 'text-muted-foreground'
)
const budgetSpentLabel = computed(() =>
t('workspacePanel.autoReload.tile.spentOfBudget', {
spent: fmtUsd(config.spentThisCycleCents),
budget: fmtUsd(config.monthlyBudgetCents ?? 0)
})
)
</script>

View File

@@ -3,22 +3,33 @@
:class="
cn(
'@container flex min-h-0 flex-1 flex-col gap-4',
// The panel runs flush to the bottom edge (BaseModalLayout 'flush');
// Invoices keeps its prior bottom gap, Overview doesn't.
// The panel runs flush to the bottom edge (BaseModalLayout 'flush'); the
// Activity/Invoices tables keep their prior bottom gap, Overview doesn't.
activeView !== 'overview' && 'pb-6'
)
"
>
<div class="flex w-full items-center gap-2">
<Button
v-for="tab in tabs"
:key="tab.key"
:variant="activeView === tab.key ? 'secondary' : 'muted-textonly'"
<div
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Button
v-for="tab in tabs"
:key="tab.key"
:variant="activeView === tab.key ? 'secondary' : 'muted-textonly'"
size="lg"
@click="setView(tab.key)"
>
{{ tab.label }}
</Button>
</div>
<SearchInput
v-if="activeView === 'activity'"
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
@click="setView(tab.key)"
>
{{ tab.label }}
</Button>
class="w-full @2xl:w-64"
/>
</div>
<BillingStatusBanner>
@@ -35,7 +46,14 @@
</template>
</BillingStatusBanner>
<WorkspaceOverviewContent v-if="activeView === 'overview'" />
<WorkspaceOverviewContent
v-if="activeView === 'overview'"
@navigate="setView"
/>
<WorkspaceActivityContent
v-else-if="activeView === 'activity'"
:search="searchQuery"
/>
<WorkspaceInvoicesContent v-else />
</div>
</template>
@@ -44,15 +62,17 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import WorkspaceActivityContent from '@/platform/workspace/components/dialogs/settings/WorkspaceActivityContent.vue'
import WorkspaceOverviewContent from '@/platform/workspace/components/dialogs/settings/WorkspaceOverviewContent.vue'
import WorkspaceInvoicesContent from '@/platform/workspace/components/dialogs/settings/WorkspaceInvoicesContent.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { cn } from '@comfyorg/tailwind-utils'
type View = 'overview' | 'invoices'
type View = 'overview' | 'activity' | 'invoices'
const { t } = useI18n()
@@ -65,7 +85,8 @@ function openInvoiceHistory() {
const tabs = computed(() => {
const base: { key: View; label: string }[] = [
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') }
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') },
{ key: 'activity', label: t('workspacePanel.planCredits.tabs.activity') }
]
// Invoices are billing details — owners/admins only.
if (permissions.value.canManageSubscription) {
@@ -77,8 +98,10 @@ const tabs = computed(() => {
return base
})
const activeView = ref<View>('overview')
const searchQuery = ref('')
function setView(view: View) {
activeView.value = view
searchQuery.value = ''
}
</script>

View File

@@ -0,0 +1,18 @@
<template>
<div
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
>
<div
class="h-full rounded-full bg-credit transition-[width]"
:style="{ width: `${clamped * 100}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { value } = defineProps<{ value: number }>()
const clamped = computed(() => Math.min(1, Math.max(0, value)))
</script>

View File

@@ -0,0 +1,315 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div
ref="tableContainer"
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead class="w-40" :aria-sort="ariaSort('date')">
<button :class="sortHeaderClass" @click="toggleSort('date')">
{{ $t('workspacePanel.activity.columns.date') }}
<i :class="sortIcon('date')" />
</button>
</TableHead>
<TableHead class="w-56" :aria-sort="ariaSort('user')">
<button :class="sortHeaderClass" @click="toggleSort('user')">
{{ $t('workspacePanel.activity.columns.user') }}
<i :class="sortIcon('user')" />
</button>
</TableHead>
<TableHead :aria-sort="ariaSort('eventType')">
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
{{ $t('workspacePanel.activity.columns.eventType') }}
<i :class="sortIcon('eventType')" />
</button>
</TableHead>
<TableHead class="w-32" :aria-sort="ariaSort('detail')">
<button :class="sortHeaderClass" @click="toggleSort('detail')">
{{ $t('workspacePanel.activity.columns.eventDetails') }}
<i :class="sortIcon('detail')" />
</button>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('credits')">
<button
:class="cn(sortHeaderClass, 'ml-auto')"
@click="toggleSort('credits')"
>
<i class="icon-[lucide--coins] size-4" />
{{ $t('workspacePanel.activity.columns.creditsUsed') }}
<i :class="sortIcon('credits')" />
</button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="event in pagedItems"
:key="event.id"
class="hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ formatDate(event.date) }}
</TableCell>
<TableCell>
<HoverCard
v-if="event.userName"
:open-delay="150"
:close-delay="0"
>
<HoverCardTrigger
as="div"
class="flex w-fit cursor-default items-center gap-3"
>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: userBadgeColor(event.userName) }"
>
<span class="text-2xs font-bold text-base-foreground">
{{ event.userName.charAt(0).toUpperCase() }}
</span>
</span>
<span class="truncate text-sm text-base-foreground">
{{ event.userName }}
</span>
</HoverCardTrigger>
<HoverCardContent class="w-64" align="start">
<div class="flex w-full flex-col gap-2">
<div class="flex h-5 items-center justify-between">
<span class="text-sm text-muted-foreground">
{{
$t(
'workspacePanel.activity.hoverCard.totalCreditsUsed'
)
}}
</span>
<span class="flex items-center gap-1">
<i
class="icon-[lucide--coins] size-4 text-muted-foreground"
/>
<span class="text-sm text-base-foreground tabular-nums">
{{
summaryFor(
event.userName
).totalCredits.toLocaleString()
}}
</span>
</span>
</div>
<div class="flex h-5 items-center justify-between">
<span class="text-sm text-muted-foreground">
{{
$t('workspacePanel.activity.hoverCard.lastActivity')
}}
</span>
<span class="text-sm text-base-foreground">
{{ lastActivityLabel(event.userName) }}
</span>
</div>
</div>
</HoverCardContent>
</HoverCard>
<span v-else class="text-sm text-muted-foreground"></span>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
<HoverCard
v-if="event.partnerNode"
:open-delay="150"
:close-delay="0"
>
<HoverCardTrigger as="span" class="cursor-default">
{{ event.eventType }}
</HoverCardTrigger>
<HoverCardContent class="w-72" align="start">
<div class="flex h-5 items-center justify-between gap-4">
<span
class="text-sm whitespace-nowrap text-muted-foreground"
>
{{
$t('workspacePanel.activity.hoverCard.partnerNodeUsed')
}}
</span>
<span class="truncate text-sm text-base-foreground">
{{ event.partnerNode }}
</span>
</div>
</HoverCardContent>
</HoverCard>
<template v-else>{{ event.eventType }}</template>
</TableCell>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ event.detail || '—' }}
</TableCell>
<TableCell
:class="
cn(
'text-right text-sm tabular-nums',
event.credited ? 'text-credit' : 'text-muted-foreground'
)
"
>
{{ event.credited ? '+' : ''
}}{{ event.credits.toLocaleString() }}
</TableCell>
</TableRow>
<TableRow v-if="pagedItems.length === 0" class="hover:bg-transparent">
<TableCell
:colspan="5"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.activity.empty') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="flex flex-col gap-3 @2xl:h-8 @2xl:flex-row @2xl:items-center">
<div
v-if="canViewTeamUsage"
class="flex flex-wrap items-center gap-x-6 gap-y-2"
>
<a
:href="fullActivityUrl"
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<i class="icon-[lucide--external-link] size-4" />
{{ $t('workspacePanel.activity.fullActivity') }}
</a>
<div class="flex items-center gap-3">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.activity.perUserHint') }}
</p>
<button
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-base-foreground transition-colors hover:text-muted-foreground"
@click="goToMembers"
>
{{ $t('workspacePanel.activity.seeMembers') }}
<i class="icon-[lucide--arrow-right] size-4" />
</button>
</div>
</div>
<Pagination
v-model:page="page"
:total="total"
:items-per-page="itemsPerPage"
class="@2xl:ml-auto"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import HoverCard from '@/components/ui/hover-card/HoverCard.vue'
import HoverCardContent from '@/components/ui/hover-card/HoverCardContent.vue'
import HoverCardTrigger from '@/components/ui/hover-card/HoverCardTrigger.vue'
import Pagination from '@/components/ui/pagination/Pagination.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
import { useWorkspaceActivity } from '@/platform/workspace/composables/useWorkspaceActivity'
import type { ActivitySortField } from '@/platform/workspace/composables/useWorkspaceActivity'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
import { cn } from '@comfyorg/tailwind-utils'
const { search } = defineProps<{ search: string }>()
const { t, d } = useI18n()
const tableContainer = ref<HTMLElement | null>(null)
const { pageSize } = useAutoPageSize(tableContainer, 1)
// Owners/admins see team-wide activity + the per-user footer; members see only
// their own usage, scoped to their name.
const { permissions } = useWorkspaceUI()
const { userDisplayName, userEmail } = useCurrentUser()
const canViewTeamUsage = computed(() => permissions.value.canManageSubscription)
const selfName = computed(() =>
canViewTeamUsage.value
? null
: userDisplayName.value || userEmail.value || t('g.you')
)
const fullActivityUrl = `${getComfyPlatformBaseUrl()}/profile/usage`
const { navigateToPanel } = useSettingsNavigation()
function goToMembers() {
requestMembersSort('credits')
navigateToPanel('workspace-members')
}
const {
page,
total,
itemsPerPage,
pagedItems,
sortField,
sortDirection,
toggleSort,
userSummaries
} = useWorkspaceActivity(() => search, pageSize, selfName)
function summaryFor(userName: string) {
return (
userSummaries.value.get(userName) ?? {
totalCredits: 0,
lastActivity: new Date()
}
)
}
function lastActivityLabel(userName: string): string {
return formatRelativeTime(summaryFor(userName).lastActivity, new Date(), {
justNow: t('workspacePanel.members.activity.justNow'),
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
})
}
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: ActivitySortField) {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
function ariaSort(
field: ActivitySortField
): 'ascending' | 'descending' | 'none' {
if (sortField.value !== field) return 'none'
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
}
function formatDate(date: Date): string {
return d(date, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
</script>

View File

@@ -191,6 +191,13 @@
</div>
</div>
<!-- Credit auto-reload; frozen alongside the tile whenever the plan can't
spend — lapsed or paused. -->
<AutoReloadSection
v-if="canManageBilling"
:frozen="isInactive || isPaused"
/>
<!-- mt-auto floats the footer to the panel's bottom edge; pb-6 (matching
the other tabs) keeps it level with their footers. -->
<div
@@ -248,6 +255,7 @@ import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import AutoReloadSection from '@/platform/workspace/components/dialogs/settings/AutoReloadSection.vue'
import { buildSupportUrl } from '@/platform/support/config'
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
@@ -257,6 +265,8 @@ import { useWorkspaceOverview } from '@/platform/workspace/composables/useWorksp
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
const emit = defineEmits<{ navigate: [view: 'activity'] }>()
const { t } = useI18n()
// Plan lifecycle actions are for the workspace creator (Owner) only; Admins and
@@ -374,12 +384,14 @@ const snapshotEmptyMessage = computed(() => {
: null
})
// Both snapshot tabs deep-link to the Members panel (sorted by spend or
// recency) until the Activity tab exists.
// Top spenders → the Members panel pre-sorted by credit usage; Recent activity
// the Activity tab.
function handleSeeMore() {
requestMembersSort(
snapshotView.value === 'recent' ? 'lastActivity' : 'credits'
)
if (snapshotView.value === 'recent') {
emit('navigate', 'activity')
return
}
requestMembersSort('credits')
navigateToPanel('workspace-members')
}
</script>

View File

@@ -0,0 +1,96 @@
import { computed, reactive } from 'vue'
import { creditsToCents } from '@/base/credits/comfyCredits'
interface AutoReloadConfig {
configured: boolean
enabled: boolean
thresholdCredits: number
reloadCredits: number
// null = no monthly budget set
monthlyBudgetCents: number | null
spentThisCycleCents: number
}
// TODO(auto-reload endpoint): there is no auto-reload API yet, so the config
// is client-side state — it starts unconfigured and edits don't persist
// across reloads.
const config = reactive<AutoReloadConfig>({
configured: false,
enabled: false,
thresholdCredits: 1000,
reloadCredits: 5000,
monthlyBudgetCents: null,
spentThisCycleCents: 0
})
export function useAutoReload() {
const isConfigured = computed(() => config.configured)
const isEnabled = computed(() => config.enabled)
const hasBudget = computed(() => config.monthlyBudgetCents != null)
const reloadCostCents = computed(() => creditsToCents(config.reloadCredits))
const budgetLeftCents = computed(() =>
config.monthlyBudgetCents == null
? 0
: Math.max(0, config.monthlyBudgetCents - config.spentThisCycleCents)
)
const budgetUsedFraction = computed(() =>
config.monthlyBudgetCents != null && config.monthlyBudgetCents > 0
? Math.min(1, config.spentThisCycleCents / config.monthlyBudgetCents)
: 0
)
const reloadsLeft = computed(() =>
hasBudget.value
? Math.floor(budgetLeftCents.value / reloadCostCents.value)
: null
)
// Budget drained while still enabled → auto-reload can't fire, so it's paused.
const isPaused = computed(
() => config.enabled && hasBudget.value && budgetLeftCents.value <= 0
)
// One reload (or none) of headroom left before the budget pauses it.
const isWarning = computed(
() =>
config.enabled &&
hasBudget.value &&
!isPaused.value &&
(reloadsLeft.value ?? Infinity) <= 1
)
function setEnabled(value: boolean) {
config.enabled = value
}
function save(next: {
thresholdCredits: number
reloadCredits: number
monthlyBudgetCents: number | null
}) {
config.configured = true
config.enabled = true
config.thresholdCredits = next.thresholdCredits
config.reloadCredits = next.reloadCredits
config.monthlyBudgetCents = next.monthlyBudgetCents
}
return {
config,
isConfigured,
isEnabled,
hasBudget,
reloadCostCents,
budgetLeftCents,
budgetUsedFraction,
reloadsLeft,
isPaused,
isWarning,
setEnabled,
save
}
}

View File

@@ -0,0 +1,126 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, ref, toValue, watch } from 'vue'
export interface ActivityEvent {
id: string
date: Date
userName: string
eventType: string
detail: string
credits: number
/** The partner node used, for 'Partner node usage' events. */
partnerNode?: string
/** True for credit inflows (auto-reload, top-up) vs. usage outflows. */
credited?: boolean
}
export interface UserSummary {
totalCredits: number
lastActivity: Date
}
// TODO(usage-activity endpoint): there is no per-workspace activity API yet;
// the ledger renders its empty state until events arrive from the backend.
const events: ActivityEvent[] = []
export type ActivitySortField =
| 'date'
| 'user'
| 'eventType'
| 'detail'
| 'credits'
export function useWorkspaceActivity(
search: MaybeRefOrGetter<string>,
pageSize: MaybeRefOrGetter<number>,
selfName: MaybeRefOrGetter<string | null> = null
) {
const page = ref(1)
const perPage = computed(() => Math.max(1, toValue(pageSize)))
const sortField = ref<ActivitySortField>('date')
const sortDirection = ref<'asc' | 'desc'>('desc')
// Members only see their own usage; credit inflows stay workspace-level.
const base = computed<ActivityEvent[]>(() => {
const self = toValue(selfName)
if (!self) return events
return events.filter((event) => event.credited || event.userName === self)
})
const filtered = computed(() => {
const q = toValue(search).trim().toLowerCase()
if (!q) return base.value
return base.value.filter(
(event) =>
event.userName.toLowerCase().includes(q) ||
event.eventType.toLowerCase().includes(q)
)
})
const sorted = computed(() => {
const dir = sortDirection.value === 'asc' ? 1 : -1
return [...filtered.value].sort((a, b) => {
if (sortField.value === 'credits') return dir * (a.credits - b.credits)
if (sortField.value === 'user')
return dir * a.userName.localeCompare(b.userName)
if (sortField.value === 'eventType')
return dir * a.eventType.localeCompare(b.eventType)
if (sortField.value === 'detail')
return dir * a.detail.localeCompare(b.detail)
return dir * (a.date.getTime() - b.date.getTime())
})
})
const total = computed(() => filtered.value.length)
const pagedItems = computed(() => {
const start = (page.value - 1) * perPage.value
return sorted.value.slice(start, start + perPage.value)
})
function toggleSort(field: ActivitySortField) {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'desc'
}
}
watch([total, perPage], ([count]) => {
const lastPage = Math.max(1, Math.ceil(count / perPage.value))
if (page.value > lastPage) page.value = lastPage
})
// Per-user rollups behind the User hover card: lifetime credits and the most
// recent event, aggregated across the whole (unpaged) list.
const userSummaries = computed(() => {
const map = new Map<string, UserSummary>()
for (const event of base.value) {
if (event.credited) continue
const existing = map.get(event.userName)
if (!existing) {
map.set(event.userName, {
totalCredits: event.credits,
lastActivity: event.date
})
} else {
existing.totalCredits += event.credits
if (event.date > existing.lastActivity)
existing.lastActivity = event.date
}
}
return map
})
return {
page,
total,
itemsPerPage: perPage,
pagedItems,
sortField,
sortDirection,
toggleSort,
userSummaries
}
}

View File

@@ -583,6 +583,18 @@ export const useDialogService = () => {
})
}
async function showAutoReloadDialog() {
const { default: component } =
await import('@/platform/workspace/components/dialogs/AutoReloadDialogContent.vue')
return dialogStore.showDialog({
key: 'auto-reload',
component,
dialogComponentProps: {
...workspaceDialogProps
}
})
}
async function showMemberLimitDialog() {
const { default: component } =
await import('@/platform/workspace/components/dialogs/RequestMoreDialogContent.vue')
@@ -773,6 +785,7 @@ export const useDialogService = () => {
showRevokeInviteDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog,
showAutoReloadDialog,
showMemberLimitDialog,
showWorkflowQueuedDialog,
showBillingComingSoonDialog,