feat(gpu): GPU agent admin card — token, queue, backfill (#114)
The FC-side control surface the operator asked for: Settings → Tagging → "GPU agent". Generate/reveal/copy/rotate the agent bearer token (with the FC URL to point the agent at), see the live job-queue depth (pending/in-flight/done/ errored, polled), and a "Queue character embedding (CCIP)" button that triggers the library backfill. Plain-HTTP-safe copy (copyText resolves on success, throws on fail). Closes the "how do I get the token in the UI" gap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<MaintenanceTile
|
||||
icon="mdi-expansion-card"
|
||||
title="GPU agent (CCIP + crops)"
|
||||
blurb="Connect a desktop-GPU agent to embed characters (CCIP) and crops. It pulls work over HTTP — your database and Redis stay private."
|
||||
:open="true"
|
||||
>
|
||||
<p class="fc-muted text-body-2 mb-3">
|
||||
The agent is a container you run on the machine with the GPU. It
|
||||
authenticates with the token below, leases jobs from this server, computes
|
||||
on the GPU, and posts results back — all over HTTP. Start it when you want
|
||||
a burst; stop it to reclaim the card.
|
||||
</p>
|
||||
|
||||
<!-- Token -->
|
||||
<div class="fc-section-h mb-1">Agent token</div>
|
||||
<div v-if="loading" class="fc-muted text-body-2">Loading…</div>
|
||||
<template v-else>
|
||||
<div v-if="tokenValue" class="fc-token">
|
||||
<code class="fc-token__val">{{ masked ? maskedToken : tokenValue }}</code>
|
||||
<v-btn
|
||||
size="x-small" variant="text" :icon="masked ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:title="masked ? 'Reveal' : 'Hide'" @click="masked = !masked"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-content-copy"
|
||||
title="Copy token" @click="onCopy"
|
||||
/>
|
||||
<v-btn
|
||||
size="small" variant="text" color="accent" class="ml-auto"
|
||||
prepend-icon="mdi-refresh" :loading="rotating" @click="onRotate"
|
||||
>Rotate</v-btn>
|
||||
</div>
|
||||
<div v-else>
|
||||
<v-btn
|
||||
color="accent" variant="flat" rounded="pill" size="small"
|
||||
prepend-icon="mdi-key-plus" :loading="rotating" @click="onRotate"
|
||||
>Generate token</v-btn>
|
||||
</div>
|
||||
<p class="fc-muted text-caption mt-2 mb-0">
|
||||
Point the agent at <code>{{ baseUrl }}</code> with this token. Rotating
|
||||
invalidates the old token — update the agent after you rotate.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Queue -->
|
||||
<div class="fc-section-h mt-5 mb-2">Work queue</div>
|
||||
<div class="fc-queue">
|
||||
<div class="fc-q"><div class="fc-q__n">{{ queue.pending }}</div><div class="fc-q__l">pending</div></div>
|
||||
<div class="fc-q"><div class="fc-q__n">{{ queue.leased }}</div><div class="fc-q__l">in flight</div></div>
|
||||
<div class="fc-q"><div class="fc-q__n fc-good">{{ queue.done }}</div><div class="fc-q__l">done</div></div>
|
||||
<div class="fc-q"><div class="fc-q__n" :class="queue.error ? 'fc-weak' : ''">{{ queue.error }}</div><div class="fc-q__l">errored</div></div>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
class="mt-4" color="accent" variant="tonal" rounded="pill" size="small"
|
||||
prepend-icon="mdi-account-box-multiple" :loading="backfilling" @click="onBackfill"
|
||||
>Queue character embedding (CCIP)</v-btn>
|
||||
<p class="fc-muted text-caption mt-2 mb-0">
|
||||
Enqueues every image that doesn't have a CCIP embedding yet. Nothing
|
||||
processes until the agent is running.
|
||||
</p>
|
||||
</MaintenanceTile>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '../../utils/toast.js'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||
import { useGpuStore } from '../../stores/gpu.js'
|
||||
import { copyText } from '../../utils/clipboard.js'
|
||||
|
||||
const store = useGpuStore()
|
||||
const loading = ref(true)
|
||||
const tokenValue = ref(null)
|
||||
const masked = ref(true)
|
||||
const rotating = ref(false)
|
||||
const backfilling = ref(false)
|
||||
const queue = ref({ pending: 0, leased: 0, done: 0, error: 0 })
|
||||
let pollTimer = null
|
||||
|
||||
const baseUrl = computed(() => window.location.origin)
|
||||
const maskedToken = computed(() => {
|
||||
const t = tokenValue.value || ''
|
||||
return t.length > 8 ? `${t.slice(0, 4)}••••••••${t.slice(-4)}` : '••••••••'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
tokenValue.value = (await store.token()).token
|
||||
} catch { /* non-fatal */ } finally {
|
||||
loading.value = false
|
||||
}
|
||||
await refreshQueue()
|
||||
pollTimer = setInterval(() => { if (!document.hidden) refreshQueue() }, 5000)
|
||||
})
|
||||
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) })
|
||||
|
||||
async function refreshQueue() {
|
||||
try { queue.value = await store.status() } catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
async function onRotate() {
|
||||
rotating.value = true
|
||||
try {
|
||||
tokenValue.value = (await store.rotateToken()).token
|
||||
masked.value = false
|
||||
toast({ text: 'New agent token generated — update your agent', type: 'success' })
|
||||
} catch (e) {
|
||||
toast({ text: `Could not rotate token: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
rotating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopy() {
|
||||
try {
|
||||
await copyText(tokenValue.value || '') // resolves on success, throws on fail
|
||||
toast({ text: 'Token copied', type: 'success' })
|
||||
} catch {
|
||||
toast({ text: 'Copy failed — select and copy manually', type: 'warning' })
|
||||
}
|
||||
}
|
||||
|
||||
async function onBackfill() {
|
||||
backfilling.value = true
|
||||
try {
|
||||
await store.backfill('ccip')
|
||||
toast({ text: 'Queued CCIP embedding — run the agent to process it', type: 'success' })
|
||||
await refreshQueue()
|
||||
} catch (e) {
|
||||
toast({ text: `Could not queue backfill: ${e.message}`, type: 'error' })
|
||||
} finally {
|
||||
backfilling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-muted { color: rgb(var(--v-theme-on-surface-variant)); }
|
||||
.fc-section-h {
|
||||
font-size: 13px; font-weight: 700; letter-spacing: 0.03em;
|
||||
text-transform: uppercase; color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
.fc-token {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
background: rgb(var(--v-theme-surface-light)); border-radius: 6px;
|
||||
padding: 4px 6px 4px 10px;
|
||||
}
|
||||
.fc-token__val {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 13px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fc-queue { display: flex; gap: 24px; }
|
||||
.fc-q__n {
|
||||
font-size: 20px; font-weight: 700; line-height: 1.1;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.fc-q__l {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-good { color: rgb(var(--v-theme-success)); }
|
||||
.fc-weak { color: rgb(var(--v-theme-error)); }
|
||||
</style>
|
||||
@@ -27,6 +27,7 @@
|
||||
<div class="fc-tile-stack">
|
||||
<MLThresholdSliders />
|
||||
<HeadsCard />
|
||||
<GpuAgentCard />
|
||||
<AllowlistTable />
|
||||
<AliasTable />
|
||||
<TagEvalCard />
|
||||
@@ -54,6 +55,7 @@ import MissingFileRepairCard from './MissingFileRepairCard.vue'
|
||||
import DbMaintenanceCard from './DbMaintenanceCard.vue'
|
||||
import MLThresholdSliders from './MLThresholdSliders.vue'
|
||||
import HeadsCard from './HeadsCard.vue'
|
||||
import GpuAgentCard from './GpuAgentCard.vue'
|
||||
import AllowlistTable from './AllowlistTable.vue'
|
||||
import AliasTable from './AliasTable.vue'
|
||||
import TagEvalCard from './TagEvalCard.vue'
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
|
||||
// GPU agent control surface (#114): the FC-side admin for the desktop agent —
|
||||
// the bearer token it authenticates with, the job-queue depth, and the backfill
|
||||
// trigger. The agent itself talks to /api/gpu/jobs/* over HTTP; nothing here
|
||||
// touches Redis/Postgres directly.
|
||||
export const useGpuStore = defineStore('gpu', () => {
|
||||
const api = useApi()
|
||||
|
||||
// { token: <string|null>, configured: bool }
|
||||
async function token() {
|
||||
return await api.get('/api/gpu/token')
|
||||
}
|
||||
|
||||
// Generate a fresh token (invalidates the old one). Returns { token }.
|
||||
async function rotateToken() {
|
||||
return await api.post('/api/gpu/token/rotate')
|
||||
}
|
||||
|
||||
// { pending, leased, done, error }
|
||||
async function status() {
|
||||
return await api.get('/api/gpu/status')
|
||||
}
|
||||
|
||||
// Enqueue a job per image lacking one for `task` (the agent drains it).
|
||||
async function backfill(task = 'ccip') {
|
||||
return await api.post('/api/gpu/backfill', { body: { task } })
|
||||
}
|
||||
|
||||
return { token, rotateToken, status, backfill }
|
||||
})
|
||||
Reference in New Issue
Block a user