avocet/web/src/components/nodes/GpuRow.vue
pyr0ball 79b9ccbd3d feat(fleet): profile editor, assignments tab, node management polish
Backend:
- app/nodes.py: fix coordinator response envelope (.get("nodes"/"services"))
- app/nodes.py: add PUT /nodes/{id}/profile (atomic YAML write + reload)
- app/nodes.py: add POST /nodes/{id}/profile/generate (coordinator-seeded skeleton)
- tests/test_nodes.py: fix mock envelopes; add deploy model + profile tests

Frontend:
- NodeManagementView: tab bar switching nodes / assignments panels
- AssignmentsTab: full product.task → model routing UI (add/edit/delete)
- ProfileEditorPanel: full YAML profile editor with GPU + service sections
- CatalogEntryFormModal: add/edit model catalog entries per service
- ServiceFormModal: add/edit service config blocks
- NodeCard, GpuRow, ServiceBadge, OllamaModelPanel, HfNodeModelPanel: polish pass
- ModelsView: model download additions
- nodes.ts: extend types for full profile editing (ServiceManaged, CatalogEntryFull)
2026-05-17 11:23:47 -07:00

129 lines
4.5 KiB
Vue

<script setup lang="ts">
import { ref, computed } from 'vue'
import ServiceBadge from './ServiceBadge.vue'
import type { GpuEntry, ServiceInfo } from '../../types/nodes'
const props = defineProps<{
gpu: GpuEntry
nodeId: string
profileLoaded: boolean
servicesCatalog: Record<string, ServiceInfo>
}>()
const emit = defineEmits<{ updated: [] }>()
const saving = ref(false)
const saveError = ref('')
const vramPct = computed(() => {
if (!props.gpu.vram_total_mb) return 0
return Math.round((props.gpu.vram_used_mb / props.gpu.vram_total_mb) * 100)
})
function serviceState(svcName: string): 'running' | 'stopped' | 'assigned-only' | 'available' | 'incompatible' | 'unknown' {
const svc = props.servicesCatalog[svcName]
if (!svc) return 'unknown'
const cap = props.gpu.compute_cap ?? 0
if (cap < svc.min_compute_cap) return 'incompatible'
if (props.gpu.services_running.includes(svcName)) return 'running'
if (props.gpu.services_assigned.includes(svcName)) return 'assigned-only'
return 'available'
}
async function toggleService(svcName: string) {
if (!props.profileLoaded || saving.value) return
const current = [...props.gpu.services_assigned]
const removing = current.includes(svcName)
if (removing && !confirm(`Remove ${svcName} from GPU ${props.gpu.gpu_id}?`)) return
const next = removing ? current.filter(s => s !== svcName) : [...current, svcName]
saving.value = true
saveError.value = ''
try {
const r = await fetch(
`/api/nodes-mgmt/nodes/${props.nodeId}/gpu/${props.gpu.gpu_id}/services`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ services: next }),
},
)
if (!r.ok) {
const data = await r.json().catch(() => ({}))
throw new Error((data as { detail?: string }).detail ?? `HTTP ${r.status}`)
}
const data = await r.json() as { ok: boolean; reloaded: boolean; warnings: string[] }
if (data.warnings?.length) saveError.value = `Saved (warning: ${data.warnings.join(', ')})`
emit('updated')
} catch (e) {
saveError.value = e instanceof Error ? e.message : 'Failed to update services'
} finally {
saving.value = false
}
}
</script>
<template>
<div class="gpu-row">
<div class="gpu-info">
<span class="gpu-label">GPU {{ gpu.gpu_id }}: {{ gpu.card }}</span>
<span v-if="gpu.compute_cap != null" class="gpu-meta">sm{{ gpu.compute_cap }}</span>
<span v-if="gpu.temp_c != null" class="gpu-meta">{{ gpu.temp_c }}°C</span>
<span v-if="gpu.utilization_pct != null" class="gpu-meta">{{ gpu.utilization_pct }}%</span>
</div>
<div class="vram-wrap">
<div
class="vram-bar"
role="progressbar"
:aria-valuenow="gpu.vram_used_mb"
aria-valuemin="0"
:aria-valuemax="gpu.vram_total_mb"
:aria-label="`VRAM: ${gpu.vram_used_mb} of ${gpu.vram_total_mb} MB used`"
>
<div class="vram-fill" :style="{ width: `${vramPct}%` }" />
</div>
<span class="vram-text">{{ gpu.vram_used_mb }} / {{ gpu.vram_total_mb }} MB ({{ vramPct }}%)</span>
</div>
<div v-if="profileLoaded" class="services-row" aria-label="Service assignments">
<ServiceBadge
v-for="(_, svcName) in servicesCatalog"
:key="String(svcName)"
:service-name="String(svcName)"
:state="serviceState(String(svcName))"
:assigned="gpu.services_assigned.includes(String(svcName))"
:disabled="saving"
@toggle="toggleService(String(svcName))"
/>
</div>
<div v-if="saveError" class="save-msg" role="alert">{{ saveError }}</div>
</div>
</template>
<style scoped>
.gpu-row {
padding: 0.5rem 0.75rem;
border-radius: 4px;
background: var(--color-surface-alt);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.gpu-info { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; font-size: 0.875rem; }
.gpu-label { font-weight: 500; color: var(--color-text); }
.gpu-meta { color: var(--color-text-muted); font-size: 0.8rem; }
.vram-wrap { display: flex; align-items: center; gap: 0.5rem; }
.vram-bar {
flex: 1;
height: 8px;
background: var(--color-border);
border-radius: 4px;
overflow: hidden;
}
.vram-fill { height: 100%; background: var(--app-primary); transition: width 0.3s; }
.vram-text { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
.services-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.save-msg { color: var(--color-warning); font-size: 0.8rem; }
</style>