feat: add NodeCard, GpuRow, ServiceBadge Vue components

This commit is contained in:
pyr0ball 2026-05-05 21:24:32 -07:00
parent 4c225b94f5
commit 0efd1aedbe
3 changed files with 349 additions and 0 deletions

View 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>

View 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>

View 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>