diff --git a/web/src/features/home/GroupCard.test.ts b/web/src/features/home/GroupCard.test.ts new file mode 100644 index 00000000..d991314c --- /dev/null +++ b/web/src/features/home/GroupCard.test.ts @@ -0,0 +1,77 @@ +import { mount } from '@vue/test-utils' + +import type { GroupSummary, HealthGroupDto } from '@/api/control/types' +import { createTestAppI18n } from '@/test/i18n' + +import GroupCard from './GroupCard.vue' + +const longName = `Primary-${'segment-'.repeat(20)}Group` +const group: GroupSummary = { + id: 9, + name: longName, + upstream_url: `https://${'long-host-'.repeat(12)}example.com/v1`, + protocols: ['openai', 'anthropic'], + models: [], + enabled: true, + key_count: 2, +} +const health: HealthGroupDto = { + id: 9, + name: longName, + enabled: true, + counts: { + total: 2, + available: 0, + cooldown: 2, + blacklisted: 0, + disabled: 0, + }, +} + +function mountCard(groupValue = group, healthValue: HealthGroupDto | undefined = health) { + const appI18n = createTestAppI18n(undefined, 'en-US') + return mount(GroupCard, { + props: { + group: groupValue, + health: healthValue, + }, + global: { + plugins: [appI18n.plugin], + stubs: { + RouterLink: { + props: ['to'], + template: '', + }, + }, + }, + }) +} + +describe('GroupCard', () => { + it('preserves long identity text and translates protocol labels', () => { + const wrapper = mountCard() + + expect(wrapper.get('h3').text()).toBe(longName) + expect(wrapper.text()).toContain('long-host-') + expect(wrapper.text()).toContain('OpenAI') + expect(wrapper.text()).toContain('Anthropic') + expect(wrapper.get('[href="/groups/9?tab=keys"]').text()).toContain('View details') + expect(wrapper.get('[href="/import?mode=existing&group_id=9"]').text()).toContain('Import keys') + }) + + it('explains no-model and no-available-key serviceability separately', async () => { + const wrapper = mountCard() + expect(wrapper.get('[data-test="group-service-reason"]').text()).toContain('no models') + + await wrapper.setProps({ + group: { + ...group, + models: [{ id: 'gpt-real', alias: '' }], + }, + }) + + expect(wrapper.get('[data-test="group-service-reason"]').text()).toMatch( + /no upstream key is currently available/i, + ) + }) +}) diff --git a/web/src/features/home/GroupCard.vue b/web/src/features/home/GroupCard.vue index 85657dad..07076660 100644 --- a/web/src/features/home/GroupCard.vue +++ b/web/src/features/home/GroupCard.vue @@ -20,6 +20,13 @@ const serviceLabel = computed(() => { if (serviceable.value === undefined) return t('home.unknown') return serviceable.value ? t('home.serviceable') : t('home.unavailable') }) +const serviceReason = computed(() => { + if (!props.group.enabled) return t('home.groupDisabledReason') + if (props.group.models.length === 0) return t('home.noModelsReason') + if (!props.health) return t('home.healthUnknownReason') + if (props.health.counts.available === 0) return t('home.noAvailableKeysReason') + return '' +}) @@ -37,10 +44,14 @@ const serviceLabel = computed(() => { {{ group.enabled ? t('home.enabled') : t('home.disabled') }} {{ - protocol + t(`common.protocols.${protocol}`) }} + + {{ serviceReason }} + + {{ t('home.models', { count: group.models.length }) }} {{ t('home.keys', { count: group.key_count }) }} @@ -80,6 +91,7 @@ const serviceLabel = computed(() => { .group-card h3 { margin: 0; font-size: 1.0625rem; + overflow-wrap: anywhere; } .group-card__header p { margin: var(--space-1) 0 0; @@ -97,6 +109,10 @@ const serviceLabel = computed(() => { .group-card__facts { color: var(--color-text-muted); } +.group-card__reason { + margin: 0; + color: var(--color-text-muted); +} .group-card__facts span + span::before { content: '·'; margin-right: var(--space-2); diff --git a/web/src/features/home/HomeView.test.ts b/web/src/features/home/HomeView.test.ts index 9f8a5590..2e7cd544 100644 --- a/web/src/features/home/HomeView.test.ts +++ b/web/src/features/home/HomeView.test.ts @@ -102,6 +102,25 @@ async function mountHome(api: FakeApi, origin?: string, queryClient?: QueryClien } describe('HomeView', () => { + it('keeps the semantic task order Operational Overview, Groups, then Connection Setup', async () => { + const api = new FakeApi() + api.when('/api/groups').resolve([groupFixture]) + api.when('/api/health').resolve(healthFixture) + api.when('/api/access-keys/options').resolve([accessKeyFixture]) + + const wrapper = await mountHome(api) + const sections = wrapper.findAll( + '[data-test="home-operational-overview"], [data-test="home-groups"], [data-test="home-connection"]', + ) + + expect(sections.map((section) => section.attributes('data-test'))).toEqual([ + 'home-operational-overview', + 'home-groups', + 'home-connection', + ]) + expect(sections[0]?.find('[data-test="home-usage-requests"]').exists()).toBe(true) + }) + it('keeps Groups visible when Health fails', async () => { const api = new FakeApi() api.when('/api/groups').resolve([groupFixture]) diff --git a/web/src/features/home/HomeView.vue b/web/src/features/home/HomeView.vue index da463615..45b02f45 100644 --- a/web/src/features/home/HomeView.vue +++ b/web/src/features/home/HomeView.vue @@ -65,102 +65,86 @@ const observedAt = computed(() => { - - - - - {{ t('home.service') }} - {{ t('home.service') }} - - - {{ t('home.online') }} - - - {{ t('home.healthStale') }} - - - {{ healthErrorMessage }} - - + + + {{ t('home.operationalOverview') }} + {{ t('home.operationalOverviewDescription') }} + + + + + + + {{ t('home.service') }} + {{ t('home.service') }} + + + {{ t('home.online') }} + + + {{ t('home.healthStale') }} + + + {{ healthErrorMessage }} + + - - - + - - {{ - t('home.revision', { revision: healthQuery.data.value.snapshot_revision }) - }} - {{ t('home.observedAt', { time: observedAt }) }} - - - {{ t('home.keyTotal', { count: healthQuery.data.value.counts.total }) }} - {{ - t('home.keyAvailable', { count: healthQuery.data.value.counts.available }) - }} - {{ - t('home.keyCooldown', { count: healthQuery.data.value.counts.cooldown }) - }} - {{ - t('home.keyBlacklisted', { count: healthQuery.data.value.counts.blacklisted }) - }} - {{ - t('home.keyDisabled', { count: healthQuery.data.value.counts.disabled }) - }} - - - + + + + {{ + t('home.revision', { revision: healthQuery.data.value.snapshot_revision }) + }} + {{ t('home.observedAt', { time: observedAt }) }} + + + {{ t('home.keyTotal', { count: healthQuery.data.value.counts.total }) }} + {{ + t('home.keyAvailable', { count: healthQuery.data.value.counts.available }) + }} + {{ + t('home.keyCooldown', { count: healthQuery.data.value.counts.cooldown }) + }} + {{ + t('home.keyBlacklisted', { count: healthQuery.data.value.counts.blacklisted }) + }} + {{ + t('home.keyDisabled', { count: healthQuery.data.value.counts.disabled }) + }} + + + - - - - - + - - - + {{ t('home.groups') }} @@ -208,6 +192,39 @@ const observedAt = computed(() => { + + + + + + + + + @@ -216,14 +233,29 @@ const observedAt = computed(() => { display: grid; gap: var(--space-8); } +.operational-overview, +.connection-section { + display: grid; + gap: var(--space-4); +} +.operational-overview__header h2, +.operational-overview__header p { + margin: 0; +} +.operational-overview__header h2 { + font-size: 1.25rem; +} +.operational-overview__header p { + margin-top: var(--space-1); + color: var(--color-text-muted); +} .home-overview { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 0.9fr); gap: var(--space-5); align-items: start; } -.service-card, -.connection-section { +.service-card { display: grid; gap: var(--space-5); } @@ -236,7 +268,7 @@ const observedAt = computed(() => { justify-content: space-between; gap: var(--space-3); } -.section-heading h2, +.section-heading h3, .groups-section h2 { margin: 0; font-size: 1.125rem; diff --git a/web/src/features/home/UsageSummaryCard.test.ts b/web/src/features/home/UsageSummaryCard.test.ts index 3ae95ec8..c983b9b2 100644 --- a/web/src/features/home/UsageSummaryCard.test.ts +++ b/web/src/features/home/UsageSummaryCard.test.ts @@ -137,7 +137,7 @@ describe('UsageSummaryCard', () => { ) }) - it('always renders three known zero quality counts when persisted usage has no quality gaps', async () => { + it('keeps known zero quality counts neutral when persisted usage has no quality gaps', async () => { const report = usageReport({ summary: { ...aggregate, @@ -163,7 +163,7 @@ describe('UsageSummaryCard', () => { for (const quality of ['missing', 'partial', 'unpriced']) { const status = wrapper.get(`[data-test="home-usage-quality-${quality}"]`) expect(status.text()).toContain('0') - expect(status.classes()).toContain('status-badge--success') + expect(status.classes()).toContain('status-badge--neutral') } expect(wrapper.get('[data-test="home-usage-tokens"]').text()).toContain('0') }) diff --git a/web/src/features/home/UsageSummaryCard.vue b/web/src/features/home/UsageSummaryCard.vue index c16a93d4..013480d9 100644 --- a/web/src/features/home/UsageSummaryCard.vue +++ b/web/src/features/home/UsageSummaryCard.vue @@ -14,6 +14,15 @@ import StatusBadge from '@/components/ui/StatusBadge.vue' import SurfaceCard from '@/components/ui/SurfaceCard.vue' import { formatEstimatedUSD } from '@/features/usage/estimated-cost' +withDefaults( + defineProps<{ + headingAs?: 'h2' | 'h3' + }>(), + { + headingAs: 'h2', + }, +) + const filters = { range: '24h' } as const const client = useApiClient() const { locale, t } = useI18n() @@ -71,7 +80,7 @@ function formatEstimatedCost(aggregate: UsageAggregateDto): string { {{ t('home.usage.eyebrow') }} - {{ t('home.usage.title') }} + {{ t('home.usage.title') }} {{ t('home.usage.description') }} {{ t('home.usage.quality.missing') }} {{ formatCount(report.summary.usage_missing_count) }} {{ t('home.usage.quality.partial') }} {{ formatCount(report.summary.partial_count) }} {{ t('home.usage.quality.unpriced') }} {{ formatCount(report.summary.unpriced_request_count) }} @@ -191,14 +200,14 @@ function formatEstimatedCost(aggregate: UsageAggregateDto): string { justify-content: space-between; gap: var(--space-4); } -.usage-summary-card__header h2, +.usage-summary-card__header :is(h2, h3), .usage-summary-card__header p { margin: 0; } -.usage-summary-card__header h2 { +.usage-summary-card__header :is(h2, h3) { font-size: 1.125rem; } -.usage-summary-card__header h2 + p { +.usage-summary-card__header :is(h2, h3) + p { margin-top: var(--space-1); color: var(--color-text-muted); } diff --git a/web/src/features/home/home-model.ts b/web/src/features/home/home-model.ts index 32f3cbbd..9dc186e4 100644 --- a/web/src/features/home/home-model.ts +++ b/web/src/features/home/home-model.ts @@ -69,8 +69,9 @@ export function buildConnectionSnippet(input: ConnectionSnippetInput): Connectio } export function isGroupServiceable(group: GroupSummary, counts?: KeyCounts): boolean | undefined { + if (!group.enabled || group.models.length === 0) return false if (!counts) return undefined - return group.enabled && group.models.length > 0 && counts.available > 0 + return counts.available > 0 } export function selectInitialAccessKey(keys: AccessKeyOptionDto[]): number | undefined { diff --git a/web/src/i18n/locales/en-US/core.ts b/web/src/i18n/locales/en-US/core.ts index 197057e9..4789b13d 100644 --- a/web/src/i18n/locales/en-US/core.ts +++ b/web/src/i18n/locales/en-US/core.ts @@ -76,6 +76,8 @@ export default { title: 'Operational overview', description: 'Review real runtime health, Group serviceability, and client connection settings.', + operationalOverview: 'Operational Overview', + operationalOverviewDescription: 'Runtime health and persisted usage from the last 24 hours.', service: 'Management service', online: 'Online', healthUnavailable: 'The service responded, but health is currently unavailable.', @@ -103,6 +105,10 @@ export default { serviceable: 'Serviceable', unavailable: 'Unavailable', unknown: 'Status unknown', + groupDisabledReason: 'This Group is disabled.', + noModelsReason: 'This Group has no models configured.', + healthUnknownReason: 'Current upstream-key availability is unknown.', + noAvailableKeysReason: 'No upstream key is currently available.', models: '{count} models', keys: '{count} keys', details: 'View details', diff --git a/web/src/i18n/locales/ja-JP/core.ts b/web/src/i18n/locales/ja-JP/core.ts index 48db1e1c..7049c196 100644 --- a/web/src/i18n/locales/ja-JP/core.ts +++ b/web/src/i18n/locales/ja-JP/core.ts @@ -76,6 +76,9 @@ export default { home: { title: '運用概要', description: '実際のランタイム状態、Group のサービス可否、クライアント接続設定を確認します。', + operationalOverview: '運用概要', + operationalOverviewDescription: + 'ランタイムの状態と過去 24 時間に永続化された使用量をまとめます。', service: '管理サービス', online: 'オンライン', healthUnavailable: 'サービスは応答しましたが、ヘルス状態を取得できません。', @@ -102,6 +105,10 @@ export default { serviceable: 'サービス可能', unavailable: 'サービス不可', unknown: '状態不明', + groupDisabledReason: 'この Group は無効です。', + noModelsReason: 'この Group にはモデルが設定されていません。', + healthUnknownReason: '現在のアップストリームキーの可用性は不明です。', + noAvailableKeysReason: '現在利用可能なアップストリームキーがありません。', models: '{count} モデル', keys: '{count} キー', details: '詳細を見る', diff --git a/web/src/i18n/locales/zh-CN/core.ts b/web/src/i18n/locales/zh-CN/core.ts index 61c7208e..65f2a814 100644 --- a/web/src/i18n/locales/zh-CN/core.ts +++ b/web/src/i18n/locales/zh-CN/core.ts @@ -75,6 +75,8 @@ export default { home: { title: '运行概览', description: '查看真实运行健康状态、Group 服务能力和客户端连接配置。', + operationalOverview: '运行概览', + operationalOverviewDescription: '汇总运行健康状态与最近 24 小时已持久化用量。', service: '管理服务', online: '在线', healthUnavailable: '服务已响应,但健康状态暂不可用。', @@ -100,6 +102,10 @@ export default { serviceable: '可服务', unavailable: '不可服务', unknown: '状态未知', + groupDisabledReason: '该 Group 已停用。', + noModelsReason: '该 Group 尚未配置模型。', + healthUnknownReason: '当前上游密钥可用性未知。', + noAvailableKeysReason: '当前没有可用的上游密钥。', models: '{count} 个模型', keys: '{count} 个密钥', details: '查看详情',
+ {{ serviceReason }} +
{{ t('home.service') }}
{{ t('home.operationalOverviewDescription') }}
{{ t('home.usage.eyebrow') }}
{{ t('home.usage.description') }}