mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 00:24:38 +00:00
feat(ollama): 支持 Cloud 官方用量自动刷新
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user