Compare commits

...

4 Commits

Author SHA1 Message Date
Alexander Brown
3103a8f499 Merge branch 'main' into glary/refactor-copy-system-info 2026-05-18 16:27:12 -07:00
Glary-Bot
de5f25ec60 review: keep SystemInfoKey type internal to systemStatsColumns 2026-05-02 02:28:27 +00:00
Glary-Bot
945eb63d27 review: address oracle feedback - drop computed cache, add cloud-mode tests
- useCopySystemInfo: format inside copySystemInfo() instead of caching
  in a computed, so plain mutable SystemStats objects re-format on each
  call
- Add regression test for plain-object mutation
- Add cloud-distribution test path covering cloud-only columns and
  formatCommitHash truncation; switch isCloud mock to a hoisted shared
  flag + vi.resetModules() so each test re-evaluates the module
2026-05-02 02:10:03 +00:00
Glary-Bot
23a31d8e4b refactor: extract CopySystemInfoButton component and useCopySystemInfo composable
Pull the system-info copy button + markdown formatter out of
SystemStatsPanel into reusable units so any component can mount the
button without duplicating the formatting logic.

- useCopySystemInfo(stats) composable owns the markdown formatting
  and clipboard write
- CopySystemInfoButton.vue is a thin wrapper around it
- systemStatsColumns module hosts the local/cloud column defs and
  display-value helper, shared by the panel and the formatter so they
  cannot drift
2026-04-28 20:06:29 +00:00
6 changed files with 341 additions and 101 deletions

View File

@@ -0,0 +1,60 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { SystemStats } from '@/schemas/apiSchema'
const mockCopySystemInfo = vi.fn()
vi.mock('@/composables/useCopySystemInfo', () => ({
useCopySystemInfo: () => ({ copySystemInfo: mockCopySystemInfo })
}))
import CopySystemInfoButton from './CopySystemInfoButton.vue'
const stats: SystemStats = {
system: {
os: 'Linux',
python_version: '3.11.5',
embedded_python: false,
comfyui_version: 'v0.3.0',
pytorch_version: '2.1.0',
argv: ['main.py'],
ram_total: 16_000_000_000,
ram_free: 8_000_000_000
},
devices: []
}
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return render(CopySystemInfoButton, {
props: { stats },
global: { plugins: [i18n] }
})
}
describe('CopySystemInfoButton', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('renders the localized label', () => {
renderComponent()
expect(screen.getByRole('button')).toHaveTextContent('Copy System Info')
})
it('invokes copySystemInfo when clicked', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByRole('button'))
expect(mockCopySystemInfo).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,18 @@
<template>
<Button variant="secondary" @click="copySystemInfo">
<i class="pi pi-copy" />
{{ $t('g.copySystemInfo') }}
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useCopySystemInfo } from '@/composables/useCopySystemInfo'
import type { SystemStats } from '@/schemas/apiSchema'
const { stats } = defineProps<{
stats: SystemStats
}>()
const { copySystemInfo } = useCopySystemInfo(() => stats)
</script>

View File

@@ -5,10 +5,7 @@
<h2 class="text-2xl font-semibold">
{{ $t('g.systemInfo') }}
</h2>
<Button variant="secondary" @click="copySystemInfo">
<i class="pi pi-copy" />
{{ $t('g.copySystemInfo') }}
</Button>
<CopySystemInfoButton :stats="props.stats" />
</div>
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
@@ -16,7 +13,7 @@
{{ col.header }}
</div>
<div :class="cn(isOutdated(col) && 'text-danger-100')">
{{ getDisplayValue(col) }}
{{ getColumnDisplayValue(props.stats, col) }}
</div>
</template>
</div>
@@ -51,116 +48,28 @@ import TabPanel from 'primevue/tabpanel'
import TabView from 'primevue/tabview'
import { computed } from 'vue'
import CopySystemInfoButton from '@/components/common/CopySystemInfoButton.vue'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { isCloud } from '@/platform/distribution/types'
import type { SystemStatsColumn } from '@/components/common/systemStatsColumns'
import {
getColumnDisplayValue,
getSystemStatsColumns
} from '@/components/common/systemStatsColumns'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
import { cn } from '@comfyorg/tailwind-utils'
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
const props = defineProps<{
stats: SystemStats
}>()
const { copyToClipboard } = useCopyToClipboard()
const systemInfo = computed(() => ({
...props.stats.system,
argv: props.stats.system.argv.join(' ')
}))
const systemColumns = getSystemStatsColumns()
const hasDevices = computed(() => props.stats.devices.length > 0)
type SystemInfoKey = keyof SystemStats['system']
type ColumnDef = {
field: SystemInfoKey
header: string
getValue?: () => string
format?: (value: string) => string
formatNumber?: (value: number) => string
}
/** Columns for local distribution */
const localColumns: ColumnDef[] = [
{ field: 'os', header: 'OS' },
{ field: 'python_version', header: 'Python Version' },
{ field: 'embedded_python', header: 'Embedded Python' },
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize },
{ field: 'installed_templates_version', header: 'Templates Version' }
]
/** Columns for cloud distribution */
const cloudColumns: ColumnDef[] = [
{ field: 'cloud_version', header: 'Cloud Version' },
{
field: 'comfyui_version',
header: 'ComfyUI Version',
format: formatCommitHash
},
{
field: 'comfyui_frontend_version',
header: 'Frontend Version',
getValue: () => frontendCommit,
format: formatCommitHash
},
{ field: 'workflow_templates_version', header: 'Templates Version' }
]
const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
function isOutdated(column: ColumnDef): boolean {
function isOutdated(column: SystemStatsColumn): boolean {
if (column.field !== 'installed_templates_version') return false
const installed = props.stats.system.installed_templates_version
const required = props.stats.system.required_templates_version
return !!installed && !!required && installed !== required
}
function getDisplayValue(column: ColumnDef) {
const value = column.getValue
? column.getValue()
: systemInfo.value[column.field]
if (column.formatNumber && typeof value === 'number') {
return column.formatNumber(value)
}
if (column.format && typeof value === 'string') {
return column.format(value)
}
return value
}
function formatSystemInfoText(): string {
const lines: string[] = ['## System Info']
for (const col of systemColumns.value) {
const display = getDisplayValue(col)
if (display !== undefined && display !== '') {
lines.push(`${col.header}: ${display}`)
}
}
if (hasDevices.value) {
lines.push('')
lines.push('## Devices')
for (const device of props.stats.devices) {
lines.push(`- ${device.name} (${device.type})`)
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
}
}
return lines.join('\n')
}
function copySystemInfo() {
copyToClipboard(formatSystemInfoText())
}
</script>

View File

@@ -0,0 +1,64 @@
import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
type SystemInfoKey = keyof SystemStats['system']
export type SystemStatsColumn = {
field: SystemInfoKey
header: string
getValue?: () => string
format?: (value: string) => string
formatNumber?: (value: number) => string
}
const localColumns: SystemStatsColumn[] = [
{ field: 'os', header: 'OS' },
{ field: 'python_version', header: 'Python Version' },
{ field: 'embedded_python', header: 'Embedded Python' },
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
{ field: 'ram_free', header: 'RAM Free', formatNumber: formatSize },
{ field: 'installed_templates_version', header: 'Templates Version' }
]
const cloudColumns: SystemStatsColumn[] = [
{ field: 'cloud_version', header: 'Cloud Version' },
{
field: 'comfyui_version',
header: 'ComfyUI Version',
format: formatCommitHash
},
{
field: 'comfyui_frontend_version',
header: 'Frontend Version',
getValue: () => frontendCommit,
format: formatCommitHash
},
{ field: 'workflow_templates_version', header: 'Templates Version' }
]
export function getSystemStatsColumns(): SystemStatsColumn[] {
return isCloud ? cloudColumns : localColumns
}
export function getColumnDisplayValue(
stats: SystemStats,
column: SystemStatsColumn
): string | number | boolean | undefined {
const systemInfo = {
...stats.system,
argv: stats.system.argv.join(' ')
}
const value = column.getValue ? column.getValue() : systemInfo[column.field]
if (column.formatNumber && typeof value === 'number') {
return column.formatNumber(value)
}
if (column.format && typeof value === 'string') {
return column.format(value)
}
return value
}

View File

@@ -0,0 +1,144 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SystemStats } from '@/schemas/apiSchema'
const mockCopyToClipboard = vi.fn()
const distributionFlags = vi.hoisted(() => ({ isCloud: false }))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({ copyToClipboard: mockCopyToClipboard })
}))
vi.mock('@/platform/distribution/types', () => distributionFlags)
const localStats: SystemStats = {
system: {
os: 'Linux',
python_version: '3.11.5',
embedded_python: false,
comfyui_version: 'v0.3.0',
pytorch_version: '2.1.0',
argv: ['main.py', '--cpu'],
ram_total: 16_000_000_000,
ram_free: 8_000_000_000,
installed_templates_version: '1.0.0'
},
devices: [
{
name: 'cpu',
type: 'cpu',
index: 0,
vram_total: 0,
vram_free: 0,
torch_vram_total: 0,
torch_vram_free: 0
}
]
}
const fullCommitHash = 'a'.repeat(40)
const cloudStats: SystemStats = {
system: {
os: 'Linux',
python_version: '3.11.5',
embedded_python: false,
comfyui_version: fullCommitHash,
pytorch_version: '2.1.0',
argv: [],
ram_total: 0,
ram_free: 0,
cloud_version: '1.2.3',
comfyui_frontend_version: fullCommitHash,
workflow_templates_version: '5.0.0'
},
devices: []
}
async function loadComposable(isCloud: boolean) {
distributionFlags.isCloud = isCloud
vi.resetModules()
const mod = await import('./useCopySystemInfo')
return mod.useCopySystemInfo
}
describe('useCopySystemInfo', () => {
beforeEach(() => {
vi.resetAllMocks()
distributionFlags.isCloud = false
})
describe('local distribution', () => {
it('formats system info as markdown and copies to clipboard', async () => {
const useCopySystemInfo = await loadComposable(false)
const { copySystemInfo } = useCopySystemInfo(localStats)
await copySystemInfo()
expect(mockCopyToClipboard).toHaveBeenCalledTimes(1)
const text = mockCopyToClipboard.mock.calls[0][0] as string
expect(text).toContain('## System Info')
expect(text).toContain('OS: Linux')
expect(text).toContain('Python Version: 3.11.5')
expect(text).toContain('Arguments: main.py --cpu')
expect(text).toContain('## Devices')
expect(text).toContain('- cpu (cpu)')
})
it('omits the Devices section when no devices are present', async () => {
const useCopySystemInfo = await loadComposable(false)
const stats: SystemStats = { ...localStats, devices: [] }
const { copySystemInfo } = useCopySystemInfo(stats)
await copySystemInfo()
const text = mockCopyToClipboard.mock.calls[0][0] as string
expect(text).not.toContain('## Devices')
})
it('reflects updates when stats are passed as a getter', async () => {
const useCopySystemInfo = await loadComposable(false)
const statsRef = ref<SystemStats>(localStats)
const { copySystemInfo } = useCopySystemInfo(() => statsRef.value)
await copySystemInfo()
expect(mockCopyToClipboard.mock.calls[0][0]).toContain('OS: Linux')
statsRef.value = {
...localStats,
system: { ...localStats.system, os: 'Windows' }
}
await copySystemInfo()
expect(mockCopyToClipboard.mock.calls[1][0]).toContain('OS: Windows')
})
it('re-reads stats on each call when given a plain mutable object', async () => {
const useCopySystemInfo = await loadComposable(false)
const stats: SystemStats = JSON.parse(JSON.stringify(localStats))
const { copySystemInfo } = useCopySystemInfo(stats)
await copySystemInfo()
expect(mockCopyToClipboard.mock.calls[0][0]).toContain('OS: Linux')
stats.system.os = 'Windows'
await copySystemInfo()
expect(mockCopyToClipboard.mock.calls[1][0]).toContain('OS: Windows')
})
})
describe('cloud distribution', () => {
it('formats cloud-specific columns and formats commit hashes', async () => {
const useCopySystemInfo = await loadComposable(true)
const { copySystemInfo } = useCopySystemInfo(cloudStats)
await copySystemInfo()
const text = mockCopyToClipboard.mock.calls[0][0] as string
const truncated = fullCommitHash.slice(0, 7)
expect(text).toContain('## System Info')
expect(text).toContain('Cloud Version: 1.2.3')
expect(text).toContain(`ComfyUI Version: ${truncated}`)
expect(text).toContain('Templates Version: 5.0.0')
expect(text).not.toContain('OS: Linux')
expect(text).not.toContain('Python Version')
})
})
})

View File

@@ -0,0 +1,45 @@
import { toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import {
getColumnDisplayValue,
getSystemStatsColumns
} from '@/components/common/systemStatsColumns'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { SystemStats } from '@/schemas/apiSchema'
import { formatSize } from '@/utils/formatUtil'
function formatSystemInfoText(stats: SystemStats): string {
const lines: string[] = ['## System Info']
for (const col of getSystemStatsColumns()) {
const display = getColumnDisplayValue(stats, col)
if (display !== undefined && display !== '') {
lines.push(`${col.header}: ${display}`)
}
}
if (stats.devices.length > 0) {
lines.push('')
lines.push('## Devices')
for (const device of stats.devices) {
lines.push(`- ${device.name} (${device.type})`)
lines.push(` VRAM Total: ${formatSize(device.vram_total)}`)
lines.push(` VRAM Free: ${formatSize(device.vram_free)}`)
lines.push(` Torch VRAM Total: ${formatSize(device.torch_vram_total)}`)
lines.push(` Torch VRAM Free: ${formatSize(device.torch_vram_free)}`)
}
}
return lines.join('\n')
}
export function useCopySystemInfo(stats: MaybeRefOrGetter<SystemStats>) {
const { copyToClipboard } = useCopyToClipboard()
function copySystemInfo() {
return copyToClipboard(formatSystemInfoText(toValue(stats)))
}
return { copySystemInfo }
}