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)
129 lines
4.5 KiB
Vue
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>
|