feat(ollama): 支持 Cloud 官方用量自动刷新

This commit is contained in:
alfadb
2026-07-23 15:50:44 +08:00
parent 2c76506e07
commit 5ac4a9fac2
47 changed files with 5571 additions and 85 deletions
@@ -552,6 +552,10 @@
<AccountQuotaInfo v-if="account.platform === 'gemini'" :account="account" />
<!-- Key/Bedrock accounts: show today stats + optional quota bars -->
<div v-else class="space-y-1">
<OllamaCloudUsageCell
v-if="account.ollama_cloud_usage?.eligible"
:account="account"
/>
<!-- Today stats row (requests, tokens, cost, user_cost) -->
<div
v-if="todayStats"
@@ -609,7 +613,10 @@
/>
<!-- No data at all -->
<div v-if="!todayStats && !todayStatsLoading && !hasApiKeyQuota" class="text-xs text-gray-400">-</div>
<div
v-if="!todayStats && !todayStatsLoading && !hasApiKeyQuota && !account.ollama_cloud_usage?.eligible"
class="text-xs text-gray-400"
>-</div>
</div>
</div>
</template>
@@ -627,6 +634,7 @@ import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
import OpenAIQuotaResetCell from './OpenAIQuotaResetCell.vue'
import GrokQuotaProbeCell from './GrokQuotaProbeCell.vue'
import OllamaCloudUsageCell from './OllamaCloudUsageCell.vue'
// Module-level cache shared across all AccountUsageCell instances
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
@@ -1635,6 +1635,12 @@
/>
</div>
<OllamaCloudUsageSettings
v-if="account?.ollama_cloud_usage?.eligible"
:account="account"
@updated="handleOllamaCloudUsageUpdated"
/>
<!-- Anthropic API Key 自动透传开关 -->
<div
v-if="account?.platform === 'anthropic' && account?.type === 'apikey'"
@@ -2598,7 +2604,8 @@ import type {
CheckMixedChannelResponse,
OpenAICompactMode,
OpenAIResponsesMode,
OpenAIEndpointCapability
OpenAIEndpointCapability,
OllamaCloudUsageState
} from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
@@ -2612,6 +2619,7 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
import OllamaCloudUsageSettings from '@/components/account/OllamaCloudUsageSettings.vue'
import {
applyAntigravityProjectID,
applyHeaderOverride,
@@ -2669,6 +2677,10 @@ const authStore = useAuthStore()
// 故隐藏代理选择器。
const isSparkShadow = computed(() => props.account?.parent_account_id != null)
const handleOllamaCloudUsageUpdated = (state: OllamaCloudUsageState) => {
if (props.account) emit('updated', { ...props.account, ollama_cloud_usage: state })
}
// Platform-specific hint for Base URL
const baseUrlHint = computed(() => {
if (!props.account) return t('admin.accounts.baseUrlHint')
@@ -0,0 +1,35 @@
<template>
<div
v-if="state?.eligible"
class="min-w-0 max-w-full space-y-1"
data-testid="ollama-cloud-usage-cell"
>
<UsageProgressBar
v-if="snapshot?.data?.five_hour"
label="5h"
:utilization="snapshot.data.five_hour.used_percent"
:resets-at="snapshot.data.five_hour.reset_at"
color="indigo"
data-testid="ollama-cloud-five-hour"
/>
<UsageProgressBar
v-if="snapshot?.data?.seven_day"
label="7d"
:utilization="snapshot.data.seven_day.used_percent"
:resets-at="snapshot.data.seven_day.reset_at"
color="emerald"
data-testid="ollama-cloud-seven-day"
/>
</div>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Account } from '@/types'
import UsageProgressBar from './UsageProgressBar.vue'
const props = defineProps<{ account: Account }>()
const state = computed(() => props.account.ollama_cloud_usage)
const snapshot = computed(() => state.value?.snapshot)
</script>
@@ -0,0 +1,270 @@
<template>
<section v-if="state?.eligible" class="space-y-4 border-t border-gray-200 pt-4 dark:border-dark-600" data-testid="ollama-cloud-usage-settings">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.accounts.ollamaCloud.title') }}
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.ollamaCloud.sessionSecurityHint') }}
</p>
</div>
<span
class="whitespace-nowrap rounded px-2 py-1 text-xs font-medium"
:class="state.configured
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'"
>
{{ state.configured ? t('admin.accounts.ollamaCloud.configured') : t('admin.accounts.ollamaCloud.notConfigured') }}
</span>
</div>
<div v-if="loading" class="flex h-20 items-center justify-center text-gray-400">
<Icon name="refresh" size="sm" class="animate-spin" />
</div>
<template v-else>
<div v-if="!state.encryption_key_configured" class="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800/50 dark:bg-amber-900/20 dark:text-amber-200">
{{ t('admin.accounts.ollamaCloud.encryptionKeyRequired') }}
</div>
<div
v-if="snapshot"
class="border-y border-gray-100 py-3 dark:border-dark-700"
data-testid="ollama-cloud-usage-details"
>
<div class="grid grid-cols-[minmax(4rem,auto)_minmax(0,1fr)] gap-x-3 gap-y-1.5 text-xs">
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.plan') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ snapshot.data?.plan || '-' }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.fiveHour') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ windowSummary(snapshot.data?.five_hour) }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.sevenDay') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ windowSummary(snapshot.data?.seven_day) }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.balance') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ snapshot.data?.balance || '-' }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.models') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ modelSummary }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.status') }}</span>
<span class="break-words font-medium text-gray-900 dark:text-white">{{ statusLabel }}</span>
<span class="text-gray-500 dark:text-gray-400">{{ t('admin.accounts.ollamaCloud.updatedAt') }}</span>
<span class="break-words text-gray-900 dark:text-white">{{ formatDate(snapshot.fetched_at || snapshot.last_attempt_at) }}</span>
</div>
<p v-if="snapshot.last_error" class="mt-2 break-words border-t border-gray-100 pt-2 text-xs text-amber-700 dark:border-dark-700 dark:text-amber-300">
{{ t(`admin.accounts.ollamaCloud.errors.${snapshot.last_error}`, snapshot.last_error) }}
</p>
</div>
<div>
<label class="input-label" for="ollama-cloud-session">{{ t('admin.accounts.ollamaCloud.sessionLabel') }}</label>
<textarea
id="ollama-cloud-session"
v-model="session"
rows="3"
class="input font-mono text-xs"
autocomplete="new-password"
data-1p-ignore
data-lpignore="true"
data-bwignore="true"
:placeholder="t('admin.accounts.ollamaCloud.sessionPlaceholder')"
/>
<p class="input-hint">{{ t('admin.accounts.ollamaCloud.writeOnlyHint') }}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
:disabled="saving || !session.trim() || !state.encryption_key_configured"
data-testid="ollama-cloud-session-save"
@click="saveSession"
>
<Icon name="check" size="xs" class="mr-1.5" />
{{ t('common.save') }}
</button>
<button
v-if="state.configured"
type="button"
class="btn btn-secondary btn-sm text-red-600 dark:text-red-400"
:disabled="saving"
data-testid="ollama-cloud-session-delete"
@click="showDeleteConfirm = true"
>
<Icon name="trash" size="xs" class="mr-1.5" />
{{ t('admin.accounts.ollamaCloud.deleteSession') }}
</button>
<button
v-if="state.configured"
type="button"
class="btn btn-secondary btn-sm"
:disabled="refreshing"
data-testid="ollama-cloud-refresh"
@click="refreshUsage"
>
<Icon name="refresh" size="xs" class="mr-1.5" :class="{ 'animate-spin': refreshing }" />
{{ t('admin.accounts.ollamaCloud.refreshNow') }}
</button>
</div>
<div v-if="state.configured" class="flex items-center justify-between gap-4 border-t border-gray-100 pt-4 dark:border-dark-700">
<div>
<label class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.ollamaCloud.autoRefresh') }}
</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.ollamaCloud.autoRefreshHint') }}
</p>
</div>
<Toggle
:model-value="state.auto_refresh_enabled"
:disabled="saving"
data-testid="ollama-cloud-auto-refresh"
@update:model-value="setAutoRefresh"
/>
</div>
</template>
<ConfirmDialog
:show="showDeleteConfirm"
:title="t('admin.accounts.ollamaCloud.deleteSession')"
:message="t('admin.accounts.ollamaCloud.deleteConfirm')"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="deleteSession"
@cancel="showDeleteConfirm = false"
/>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage, extractI18nErrorMessage } from '@/utils/apiError'
import type { Account, OllamaCloudUsageState, OllamaCloudUsageWindow } from '@/types'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Toggle from '@/components/common/Toggle.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ account: Account }>()
const emit = defineEmits<{ updated: [state: OllamaCloudUsageState] }>()
const { t } = useI18n()
const appStore = useAppStore()
const state = ref<OllamaCloudUsageState | null>(props.account.ollama_cloud_usage ?? null)
const session = ref('')
const loading = ref(false)
const saving = ref(false)
const refreshing = ref(false)
const showDeleteConfirm = ref(false)
const snapshot = computed(() => state.value?.snapshot)
const statusLabel = computed(() => {
if (!snapshot.value) return t('admin.accounts.ollamaCloud.notRefreshed')
if (snapshot.value.status === 'unauthorized') return t('admin.accounts.ollamaCloud.unauthorized')
if (snapshot.value.status === 'failed') return t('admin.accounts.ollamaCloud.failed')
return t('admin.accounts.ollamaCloud.ok')
})
const modelSummary = computed(() => snapshot.value?.data?.models?.map(model => {
const window = model.window === 'five_hour'
? t('admin.accounts.ollamaCloud.fiveHourShort')
: t('admin.accounts.ollamaCloud.sevenDayShort')
return `${window} ${model.model}: ${model.requests}`
}).join(', ') || '-')
const formatPercent = (value?: number) => typeof value === 'number' && Number.isFinite(value)
? `${value.toFixed(value % 1 ? 1 : 0)}%`
: '-'
const formatDate = (value?: string) => {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
}
const windowSummary = (window?: OllamaCloudUsageWindow) => {
if (!window) return '-'
const reset = window.reset_at ? formatDate(window.reset_at) : window.reset_text
return reset
? t('admin.accounts.ollamaCloud.windowWithReset', { percent: formatPercent(window.used_percent), reset })
: formatPercent(window.used_percent)
}
const applyState = (next: OllamaCloudUsageState) => {
state.value = next
emit('updated', next)
}
const load = async () => {
loading.value = true
try {
applyState(await adminAPI.accounts.getOllamaCloudUsage(props.account.id))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.loadFailed')))
} finally {
loading.value = false
}
}
const saveSession = async () => {
if (!session.value.trim()) return
saving.value = true
try {
applyState(await adminAPI.accounts.saveOllamaCloudUsageSession(props.account.id, session.value))
session.value = ''
appStore.showSuccess(t('admin.accounts.ollamaCloud.sessionSaved'))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.sessionSaveFailed')))
} finally {
saving.value = false
}
}
const deleteSession = async () => {
saving.value = true
showDeleteConfirm.value = false
try {
applyState(await adminAPI.accounts.deleteOllamaCloudUsageSession(props.account.id))
session.value = ''
appStore.showSuccess(t('admin.accounts.ollamaCloud.sessionDeleted'))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.sessionDeleteFailed')))
} finally {
saving.value = false
}
}
const setAutoRefresh = async (enabled: boolean) => {
saving.value = true
try {
applyState(await adminAPI.accounts.setOllamaCloudUsageAutoRefresh(props.account.id, enabled))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('admin.accounts.ollamaCloud.autoRefreshFailed')))
} finally {
saving.value = false
}
}
const refreshUsage = async () => {
refreshing.value = true
try {
applyState(await adminAPI.accounts.refreshOllamaCloudUsage(props.account.id))
appStore.showSuccess(t('admin.accounts.ollamaCloud.refreshSuccess'))
} catch (error) {
appStore.showError(extractI18nErrorMessage(
error,
t,
'admin.accounts.ollamaCloud.errors',
t('admin.accounts.ollamaCloud.refreshFailed')
))
} finally {
refreshing.value = false
}
}
watch(() => props.account.id, () => {
state.value = props.account.ollama_cloud_usage ?? null
session.value = ''
if (!state.value) void load()
})
onMounted(() => {
if (!state.value) void load()
})
</script>
@@ -72,6 +72,47 @@ describe('AccountUsageCell', () => {
})
})
it('renders eligible Ollama Cloud state inside the unified usage cell', () => {
const wrapper = mount(AccountUsageCell, {
props: {
account: makeAccount({
id: 9001,
platform: 'openai',
type: 'apikey',
ollama_cloud_usage: {
account_id: 9001,
eligible: true,
configured: true,
auto_refresh_enabled: true,
encryption_key_configured: true,
snapshot: {
status: 'ok',
last_attempt_at: '2026-07-23T00:00:00Z',
next_refresh_at: '2026-07-23T01:00:00Z',
data: {
five_hour: { used_percent: 12 },
seven_day: { used_percent: 34 }
}
}
}
})
},
global: {
stubs: {
OllamaCloudUsageCell: {
props: ['account'],
template: '<div data-test="embedded-ollama">{{ account.ollama_cloud_usage.snapshot.data.five_hour.used_percent }}</div>'
},
UsageProgressBar: true,
AccountQuotaInfo: true
}
}
})
expect(wrapper.get('[data-test="embedded-ollama"]').text()).toBe('12')
expect(getUsage).not.toHaveBeenCalled()
})
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
getUsage.mockResolvedValue({
antigravity_quota: {
@@ -0,0 +1,104 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import OllamaCloudUsageCell from '../OllamaCloudUsageCell.vue'
import UsageProgressBar from '../UsageProgressBar.vue'
import type { Account, OllamaCloudUsageState } from '@/types'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key })
}
})
const usageState = (): OllamaCloudUsageState => ({
account_id: 7,
eligible: true,
configured: true,
auto_refresh_enabled: false,
encryption_key_configured: true,
snapshot: {
status: 'ok',
fetched_at: '2026-07-22T12:00:00Z',
last_attempt_at: '2026-07-22T12:00:00Z',
next_refresh_at: '2026-07-22T13:00:00Z',
data: {
plan: 'max',
five_hour: { used_percent: 5.6, reset_at: '2026-07-23T03:00:00Z' },
seven_day: { used_percent: 14.2, reset_at: '2026-07-29T00:00:00Z' },
balance: '$0',
models: [
{ model: 'gpt-oss:120b-cloud', window: 'five_hour', requests: 2 },
{ model: 'gpt-oss:120b-cloud', window: 'seven_day', requests: 12 }
]
}
}
})
const account = (state = usageState()): Account => ({
id: 7,
name: 'ollama',
platform: 'openai',
type: 'apikey',
ollama_cloud_usage: state,
proxy_id: null,
concurrency: 1,
priority: 1,
status: 'active',
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: false,
created_at: '2026-07-22T00:00:00Z',
updated_at: '2026-07-22T00:00:00Z',
schedulable: true,
rate_limited_at: null,
rate_limit_reset_at: null,
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
session_window_start: null,
session_window_end: null,
session_window_status: null
})
describe('OllamaCloudUsageCell', () => {
it('renders only native 5h and 7d windows in a shrinkable mobile-safe cell', () => {
const wrapper = mount(OllamaCloudUsageCell, { props: { account: account() } })
const cell = wrapper.get('[data-testid="ollama-cloud-usage-cell"]')
expect(cell.classes()).toEqual(expect.arrayContaining(['min-w-0', 'max-w-full']))
expect(cell.classes()).not.toContain('min-w-[12rem]')
const bars = wrapper.findAllComponents(UsageProgressBar)
expect(bars).toHaveLength(2)
expect(bars[0].props()).toMatchObject({
label: '5h',
utilization: 5.6,
resetsAt: '2026-07-23T03:00:00Z'
})
expect(bars[1].props()).toMatchObject({
label: '7d',
utilization: 14.2,
resetsAt: '2026-07-29T00:00:00Z'
})
expect(wrapper.find('[data-testid="ollama-cloud-usage-details"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="ollama-cloud-usage-refresh"]').exists()).toBe(false)
expect(wrapper.findAll('button')).toHaveLength(0)
expect(wrapper.text()).not.toContain('max')
expect(wrapper.text()).not.toContain('$0')
expect(wrapper.text()).not.toContain('gpt-oss:120b-cloud')
})
it('reacts to an account snapshot update without a list-cell refresh action', async () => {
const wrapper = mount(OllamaCloudUsageCell, { props: { account: account() } })
const next = usageState()
next.snapshot!.data!.five_hour!.used_percent = 43
await wrapper.setProps({ account: account(next) })
expect(wrapper.findAllComponents(UsageProgressBar)[0].props('utilization')).toBe(43)
expect(wrapper.findAll('button')).toHaveLength(0)
})
})
@@ -0,0 +1,175 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import OllamaCloudUsageSettings from '../OllamaCloudUsageSettings.vue'
import type { Account, OllamaCloudUsageState } from '@/types'
const api = vi.hoisted(() => ({
getOllamaCloudUsage: vi.fn(),
saveOllamaCloudUsageSession: vi.fn(),
deleteOllamaCloudUsageSession: vi.fn(),
setOllamaCloudUsageAutoRefresh: vi.fn(),
refreshOllamaCloudUsage: vi.fn()
}))
const notifications = vi.hoisted(() => ({ showSuccess: vi.fn(), showError: vi.fn() }))
vi.mock('@/api/admin', () => ({ adminAPI: { accounts: api } }))
vi.mock('@/stores/app', () => ({ useAppStore: () => notifications }))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => {
if (key === 'admin.accounts.ollamaCloud.errors.OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED') {
return `retry in ${params?.retry_after_seconds} seconds`
}
if (key === 'admin.accounts.ollamaCloud.fiveHourShort') return '5h'
if (key === 'admin.accounts.ollamaCloud.sevenDayShort') return '7d'
return key
}
})
}
})
const state = (overrides: Partial<OllamaCloudUsageState> = {}): OllamaCloudUsageState => ({
account_id: 7,
eligible: true,
configured: false,
auto_refresh_enabled: false,
encryption_key_configured: true,
...overrides
})
const detailedState = (plan = 'max'): OllamaCloudUsageState => state({
configured: true,
snapshot: {
status: 'ok',
fetched_at: '2026-07-22T12:00:00Z',
last_attempt_at: '2026-07-22T12:00:00Z',
next_refresh_at: '2026-07-22T13:00:00Z',
data: {
plan,
five_hour: { used_percent: 5.6, reset_at: '2026-07-23T03:00:00Z' },
seven_day: { used_percent: 14.2, reset_at: '2026-07-29T00:00:00Z' },
balance: '$0',
models: [
{ model: 'gpt-oss:120b-cloud', window: 'five_hour', requests: 2 },
{ model: 'gpt-oss:120b-cloud', window: 'seven_day', requests: 12 }
]
}
}
})
const account = (usage: OllamaCloudUsageState = state()): Account => ({
id: 7,
name: 'ollama',
platform: 'anthropic',
type: 'apikey',
ollama_cloud_usage: usage,
proxy_id: null,
concurrency: 1,
priority: 1,
status: 'active',
error_message: null,
last_used_at: null,
expires_at: null,
auto_pause_on_expired: false,
created_at: '2026-07-22T00:00:00Z',
updated_at: '2026-07-22T00:00:00Z',
schedulable: true,
rate_limited_at: null,
rate_limit_reset_at: null,
overload_until: null,
temp_unschedulable_until: null,
temp_unschedulable_reason: null,
session_window_start: null,
session_window_end: null,
session_window_status: null
})
describe('OllamaCloudUsageSettings', () => {
beforeEach(() => {
Object.values(api).forEach(mock => mock.mockReset())
notifications.showSuccess.mockReset()
notifications.showError.mockReset()
})
it('uses the existing account state without an immediate duplicate GET', async () => {
api.saveOllamaCloudUsageSession.mockResolvedValueOnce(state({ configured: true }))
const wrapper = mount(OllamaCloudUsageSettings, { props: { account: account() } })
await flushPromises()
const input = wrapper.get('#ollama-cloud-session')
await input.setValue('wos-session=browser-secret')
await wrapper.get('[data-testid="ollama-cloud-session-save"]').trigger('click')
await flushPromises()
expect(api.getOllamaCloudUsage).not.toHaveBeenCalled()
expect(api.saveOllamaCloudUsageSession).toHaveBeenCalledWith(7, 'wos-session=browser-secret')
expect((input.element as HTMLTextAreaElement).value).toBe('')
expect(wrapper.text()).not.toContain('browser-secret')
expect(wrapper.emitted('updated')?.at(-1)?.[0]).toMatchObject({ configured: true })
})
it('fails closed when the persistent encryption key is unavailable', async () => {
const wrapper = mount(OllamaCloudUsageSettings, {
props: { account: account(state({ encryption_key_configured: false })) }
})
await flushPromises()
await wrapper.get('#ollama-cloud-session').setValue('wos-session=secret')
expect(wrapper.get('[data-testid="ollama-cloud-session-save"]').attributes('disabled')).toBeDefined()
expect(wrapper.text()).toContain('admin.accounts.ollamaCloud.encryptionKeyRequired')
expect(api.saveOllamaCloudUsageSession).not.toHaveBeenCalled()
})
it('updates the account-level automatic refresh switch through its dedicated endpoint', async () => {
api.setOllamaCloudUsageAutoRefresh.mockResolvedValueOnce(state({ configured: true, auto_refresh_enabled: true }))
const wrapper = mount(OllamaCloudUsageSettings, {
props: { account: account(state({ configured: true })) }
})
await flushPromises()
await wrapper.get('[data-testid="ollama-cloud-auto-refresh"]').trigger('click')
await flushPromises()
expect(api.setOllamaCloudUsageAutoRefresh).toHaveBeenCalledWith(7, true)
})
it('keeps plan, balance, model, status, and manual refresh details in the edit settings', async () => {
api.refreshOllamaCloudUsage.mockResolvedValueOnce(detailedState('pro'))
const wrapper = mount(OllamaCloudUsageSettings, {
props: { account: account(detailedState()) }
})
await flushPromises()
const details = wrapper.get('[data-testid="ollama-cloud-usage-details"]')
expect(details.text()).toContain('max')
expect(details.text()).toContain('$0')
expect(details.text()).toContain('5h gpt-oss:120b-cloud: 2')
expect(details.text()).toContain('7d gpt-oss:120b-cloud: 12')
expect(details.text()).toContain('admin.accounts.ollamaCloud.ok')
await wrapper.get('[data-testid="ollama-cloud-refresh"]').trigger('click')
await flushPromises()
expect(api.refreshOllamaCloudUsage).toHaveBeenCalledWith(7)
expect(wrapper.get('[data-testid="ollama-cloud-usage-details"]').text()).toContain('pro')
expect(notifications.showSuccess).toHaveBeenCalled()
})
it('shows the structured manual refresh limit from the edit settings', async () => {
api.refreshOllamaCloudUsage.mockRejectedValueOnce({
status: 429,
reason: 'OLLAMA_CLOUD_USAGE_REFRESH_RATE_LIMITED',
metadata: { retry_after_seconds: '18' }
})
const wrapper = mount(OllamaCloudUsageSettings, {
props: { account: account(detailedState()) }
})
await wrapper.get('[data-testid="ollama-cloud-refresh"]').trigger('click')
await flushPromises()
expect(notifications.showError).toHaveBeenCalledWith('retry in 18 seconds')
})
})
+3 -2
View File
@@ -70,12 +70,13 @@
<div
v-for="column in dataColumns"
:key="column.key"
class="flex items-start justify-between gap-4"
:data-field="column.key"
class="flex min-w-0 items-start justify-between gap-4"
>
<span class="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400">
{{ column.label }}
</span>
<div class="text-right text-sm text-gray-900 dark:text-gray-100">
<div class="min-w-0 max-w-full text-right text-sm text-gray-900 dark:text-gray-100">
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
</slot>
@@ -309,6 +309,35 @@ describe('DataTable', () => {
expect(wrapper.emitted('selectionChange')?.at(-1)?.[0]).toEqual([99, 2])
})
it('keeps the single usage field shrinkable in a 320px mobile card', () => {
stubMobileMatchMedia()
const viewport = document.createElement('div')
viewport.style.width = '320px'
document.body.appendChild(viewport)
const wrapper = mount(DataTable, {
attachTo: viewport,
props: {
columns: [{ key: 'usage', label: 'Usage' }],
data: [{ id: 1, usage: 'snapshot' }],
rowKey: 'id'
},
slots: {
'cell-usage': '<div data-test="usage-cell">snapshot</div>'
}
})
expect(viewport.style.width).toBe('320px')
expect(wrapper.findAll('[data-field="usage"]')).toHaveLength(1)
expect(wrapper.find('[data-field="ollama_cloud_usage"]').exists()).toBe(false)
const field = wrapper.get('[data-field="usage"]')
expect(field.classes()).toContain('min-w-0')
expect(field.get('div').classes()).toEqual(expect.arrayContaining(['min-w-0', 'max-w-full']))
expect(wrapper.findAll('[data-test="usage-cell"]')).toHaveLength(1)
wrapper.unmount()
viewport.remove()
})
it('offers current-page select all in the mobile card layout', async () => {
stubMobileMatchMedia()
const wrapper = mount(DataTable, {