feat: add NodeCard, GpuRow, ServiceBadge Vue components
This commit is contained in:
parent
4c225b94f5
commit
0efd1aedbe
3 changed files with 349 additions and 0 deletions
147
web/src/components/nodes/GpuRow.vue
Normal file
147
web/src/components/nodes/GpuRow.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import ServiceBadge from './ServiceBadge.vue'
|
||||||
|
|
||||||
|
interface GpuEntry {
|
||||||
|
gpu_id: number
|
||||||
|
card: string
|
||||||
|
vram_total_mb: number
|
||||||
|
vram_used_mb: number
|
||||||
|
vram_free_mb: number
|
||||||
|
temp_c: number | null
|
||||||
|
utilization_pct: number | null
|
||||||
|
compute_cap: number | null
|
||||||
|
services_assigned: string[]
|
||||||
|
services_running: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceInfo {
|
||||||
|
min_compute_cap: number
|
||||||
|
max_mb: number
|
||||||
|
catalog_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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 'stopped'
|
||||||
|
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(--bg-secondary, #111);
|
||||||
|
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; }
|
||||||
|
.gpu-meta { color: var(--text-secondary, #888); font-size: 0.8rem; }
|
||||||
|
.vram-wrap { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.vram-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-bar, #2a2a2a);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.vram-fill { height: 100%; background: var(--color-primary, #4080ff); transition: width 0.3s; }
|
||||||
|
.vram-text { font-size: 0.75rem; color: var(--text-secondary, #888); white-space: nowrap; }
|
||||||
|
.services-row { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||||
|
.save-msg { color: var(--color-warning, #ed8936); font-size: 0.8rem; }
|
||||||
|
</style>
|
||||||
121
web/src/components/nodes/NodeCard.vue
Normal file
121
web/src/components/nodes/NodeCard.vue
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import GpuRow from './GpuRow.vue'
|
||||||
|
import OllamaModelPanel from './OllamaModelPanel.vue'
|
||||||
|
import HfNodeModelPanel from './HfNodeModelPanel.vue'
|
||||||
|
|
||||||
|
interface GpuEntry {
|
||||||
|
gpu_id: number
|
||||||
|
card: string
|
||||||
|
vram_total_mb: number
|
||||||
|
vram_used_mb: number
|
||||||
|
vram_free_mb: number
|
||||||
|
temp_c: number | null
|
||||||
|
utilization_pct: number | null
|
||||||
|
compute_cap: number | null
|
||||||
|
services_assigned: string[]
|
||||||
|
services_running: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceInfo {
|
||||||
|
min_compute_cap: number
|
||||||
|
max_mb: number
|
||||||
|
catalog_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeSummary {
|
||||||
|
node_id: string
|
||||||
|
online: boolean
|
||||||
|
agent_url: string
|
||||||
|
gpus: GpuEntry[]
|
||||||
|
profile_loaded: boolean
|
||||||
|
services_catalog: Record<string, ServiceInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{ node: NodeSummary }>()
|
||||||
|
const emit = defineEmits<{ updated: [] }>()
|
||||||
|
|
||||||
|
const showOllama = ref(false)
|
||||||
|
const showHf = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="node-card" :class="{ offline: !node.online }">
|
||||||
|
<header class="node-card-header">
|
||||||
|
<div class="node-identity">
|
||||||
|
<span
|
||||||
|
class="status-dot"
|
||||||
|
:class="node.online ? 'online' : 'offline'"
|
||||||
|
:aria-label="node.online ? 'Online' : 'Offline'"
|
||||||
|
role="img"
|
||||||
|
/>
|
||||||
|
<h2 class="node-name">{{ node.node_id }}</h2>
|
||||||
|
<span class="node-agent">{{ node.agent_url }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="node.profile_loaded" class="node-actions">
|
||||||
|
<button class="btn-secondary btn-sm" @click="showOllama = !showOllama">
|
||||||
|
{{ showOllama ? 'Hide Ollama' : 'Ollama' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary btn-sm" @click="showHf = !showHf">
|
||||||
|
{{ showHf ? 'Hide Catalog' : 'Catalog' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="!node.profile_loaded" class="no-profile" role="status">
|
||||||
|
No profile configured for this node. GPU stats are visible; service assignment is disabled.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gpu-list">
|
||||||
|
<GpuRow
|
||||||
|
v-for="gpu in node.gpus"
|
||||||
|
:key="gpu.gpu_id"
|
||||||
|
:gpu="gpu"
|
||||||
|
:node-id="node.node_id"
|
||||||
|
:profile-loaded="node.profile_loaded"
|
||||||
|
:services-catalog="node.services_catalog"
|
||||||
|
@updated="emit('updated')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OllamaModelPanel v-if="showOllama" :node-id="node.node_id" />
|
||||||
|
<HfNodeModelPanel
|
||||||
|
v-if="showHf"
|
||||||
|
:node-id="node.node_id"
|
||||||
|
:services-catalog="node.services_catalog"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.node-card {
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-card, #1a1a1a);
|
||||||
|
}
|
||||||
|
.node-card.offline { opacity: 0.65; }
|
||||||
|
.node-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.node-identity { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.node-name { margin: 0; font-size: 1rem; font-weight: 600; }
|
||||||
|
.node-agent { color: var(--text-secondary, #888); font-size: 0.8rem; font-family: monospace; }
|
||||||
|
.status-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.status-dot.online { background: var(--color-success, #48bb78); }
|
||||||
|
.status-dot.offline { background: var(--color-warning, #ed8936); }
|
||||||
|
.node-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||||
|
.no-profile {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--bg-notice, #1e1e1e);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.gpu-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
|
</style>
|
||||||
81
web/src/components/nodes/ServiceBadge.vue
Normal file
81
web/src/components/nodes/ServiceBadge.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
type ServiceState =
|
||||||
|
| 'running'
|
||||||
|
| 'stopped'
|
||||||
|
| 'assigned-only'
|
||||||
|
| 'available'
|
||||||
|
| 'incompatible'
|
||||||
|
| 'vram-tight'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
serviceName: string
|
||||||
|
state: ServiceState
|
||||||
|
assigned: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ toggle: [] }>()
|
||||||
|
|
||||||
|
const STATE_LABELS: Record<string, string> = {
|
||||||
|
running: 'Running',
|
||||||
|
stopped: 'Stopped',
|
||||||
|
'assigned-only': 'Assigned',
|
||||||
|
available: 'Available',
|
||||||
|
incompatible: 'Incompatible',
|
||||||
|
'vram-tight': 'VRAM tight',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_ICONS: Record<string, string> = {
|
||||||
|
running: '▶',
|
||||||
|
stopped: '⏹',
|
||||||
|
'assigned-only': '📌',
|
||||||
|
available: '○',
|
||||||
|
incompatible: '✕',
|
||||||
|
'vram-tight': '⚠',
|
||||||
|
unknown: '?',
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
if (!props.disabled && props.state !== 'incompatible') emit('toggle')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="service-badge"
|
||||||
|
:class="[`state-${state}`, { assigned, 'is-disabled': disabled || state === 'incompatible' }]"
|
||||||
|
:aria-pressed="assigned"
|
||||||
|
:aria-label="`${serviceName}: ${STATE_LABELS[state] ?? state}${assigned ? ' (assigned)' : ''}`"
|
||||||
|
:disabled="disabled || state === 'incompatible'"
|
||||||
|
@click="handleToggle"
|
||||||
|
>
|
||||||
|
<span class="badge-icon" aria-hidden="true">{{ STATE_ICONS[state] ?? '?' }}</span>
|
||||||
|
<span class="badge-name">{{ serviceName }}</span>
|
||||||
|
<span class="badge-state">{{ STATE_LABELS[state] ?? state }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.service-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
background: var(--bg-badge, #1e1e1e);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
.service-badge:hover:not(.is-disabled) { opacity: 0.8; }
|
||||||
|
.service-badge.is-disabled { cursor: not-allowed; opacity: 0.5; }
|
||||||
|
.service-badge.state-running { border-color: var(--color-success, #48bb78); }
|
||||||
|
.service-badge.state-stopped { border-color: var(--color-warning, #ed8936); }
|
||||||
|
.service-badge.state-assigned-only { border-color: var(--color-info, #4299e1); }
|
||||||
|
.service-badge.state-incompatible { border-color: var(--color-error, #fc8181); }
|
||||||
|
.service-badge.state-vram-tight { border-color: var(--color-warning, #ed8936); }
|
||||||
|
.badge-state { color: var(--text-secondary, #888); }
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue