Adds SkillsView.vue (cloud-skill CRUD, per-host entitlement grant/revoke, read-only host local-skill inventory), skill API client methods + types, and wires it into App.vue behind the skills:admin scope. Console typecheck/build/tests green (20 passed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { LoaderCircle, Pencil, Plus, RefreshCw, Trash2, X } from "@lucide/vue";
|
||||
import {
|
||||
createCloudSkill,
|
||||
deleteCloudSkill,
|
||||
grantCloudSkillEntitlement,
|
||||
listCloudSkillEntitlements,
|
||||
listCloudSkills,
|
||||
revokeCloudSkillEntitlement,
|
||||
updateCloudSkill,
|
||||
getHostSkillInventory,
|
||||
type CloudSkillPayload,
|
||||
} from "../api";
|
||||
import type { CloudSkill, CloudSkillKind, HostSkillInventoryResponse } from "../types";
|
||||
|
||||
defineProps<{ canAdmin: boolean }>();
|
||||
|
||||
const skills = ref<CloudSkill[]>([]);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const successMessage = ref("");
|
||||
const editingId = ref<string | null>(null);
|
||||
|
||||
const entitlementsFor = ref<Record<string, string[]>>({});
|
||||
const entitlementHostInput = ref<Record<string, string>>({});
|
||||
|
||||
const inventoryHostId = ref("");
|
||||
const inventory = ref<HostSkillInventoryResponse | null>(null);
|
||||
const inventoryLoading = ref(false);
|
||||
|
||||
const form = reactive<CloudSkillPayload>({
|
||||
name: "",
|
||||
kind: "knowledge",
|
||||
description: "",
|
||||
tags: [],
|
||||
content: "",
|
||||
steps: [],
|
||||
parameters: {},
|
||||
});
|
||||
const tagsInput = ref("");
|
||||
const stepsJson = ref("[]");
|
||||
const parametersJson = ref("{}");
|
||||
const formError = ref("");
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
form.name = "";
|
||||
form.kind = "knowledge";
|
||||
form.description = "";
|
||||
form.tags = [];
|
||||
form.content = "";
|
||||
form.steps = [];
|
||||
form.parameters = {};
|
||||
tagsInput.value = "";
|
||||
stepsJson.value = "[]";
|
||||
parametersJson.value = "{}";
|
||||
formError.value = "";
|
||||
}
|
||||
|
||||
function showError(error: unknown, fallback: string) {
|
||||
successMessage.value = "";
|
||||
errorMessage.value = error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const response = await listCloudSkills();
|
||||
skills.value = response.items;
|
||||
await Promise.all(skills.value.map(loadEntitlements));
|
||||
} catch (error) {
|
||||
showError(error, "failed to load skills");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntitlements(skill: CloudSkill) {
|
||||
try {
|
||||
const resp = await listCloudSkillEntitlements(skill.id);
|
||||
entitlementsFor.value[skill.id] = resp.host_ids;
|
||||
} catch {
|
||||
entitlementsFor.value[skill.id] = [];
|
||||
}
|
||||
}
|
||||
|
||||
function editSkill(skill: CloudSkill) {
|
||||
editingId.value = skill.id;
|
||||
form.name = skill.name;
|
||||
form.kind = skill.kind;
|
||||
form.description = skill.description;
|
||||
form.tags = [...skill.tags];
|
||||
tagsInput.value = skill.tags.join(", ");
|
||||
form.content = skill.content;
|
||||
form.steps = skill.steps;
|
||||
form.parameters = skill.parameters;
|
||||
stepsJson.value = JSON.stringify(skill.steps, null, 2);
|
||||
parametersJson.value = JSON.stringify(skill.parameters, null, 2);
|
||||
formError.value = "";
|
||||
successMessage.value = "";
|
||||
}
|
||||
|
||||
function buildPayload(): CloudSkillPayload | null {
|
||||
if (!form.name.trim()) {
|
||||
formError.value = "name is required";
|
||||
return null;
|
||||
}
|
||||
let steps: Record<string, unknown>[] = [];
|
||||
let parameters: Record<string, Record<string, unknown>> = {};
|
||||
if (form.kind === "flow_template") {
|
||||
try {
|
||||
steps = JSON.parse(stepsJson.value || "[]");
|
||||
parameters = JSON.parse(parametersJson.value || "{}");
|
||||
} catch {
|
||||
formError.value = "steps/parameters must be valid JSON";
|
||||
return null;
|
||||
}
|
||||
} else if (!form.content.trim()) {
|
||||
formError.value = "knowledge skill content is required";
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: form.name.trim(),
|
||||
kind: form.kind as CloudSkillKind,
|
||||
description: form.description,
|
||||
tags: tagsInput.value.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
content: form.content,
|
||||
steps,
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSkill() {
|
||||
formError.value = "";
|
||||
const payload = buildPayload();
|
||||
if (payload === null) return;
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateCloudSkill(editingId.value, payload);
|
||||
successMessage.value = "skill updated";
|
||||
} else {
|
||||
await createCloudSkill(payload);
|
||||
successMessage.value = "skill created";
|
||||
}
|
||||
resetForm();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error, "failed to save skill");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSkill(skill: CloudSkill) {
|
||||
try {
|
||||
await deleteCloudSkill(skill.id);
|
||||
successMessage.value = "skill deleted";
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error, "failed to delete skill");
|
||||
}
|
||||
}
|
||||
|
||||
async function addHost(skillId: string) {
|
||||
const hostId = (entitlementHostInput.value[skillId] || "").trim();
|
||||
if (!hostId) return;
|
||||
try {
|
||||
await grantCloudSkillEntitlement(skillId, hostId);
|
||||
entitlementHostInput.value[skillId] = "";
|
||||
await loadEntitlements(skills.value.find((s) => s.id === skillId)!);
|
||||
} catch (error) {
|
||||
showError(error, "failed to grant entitlement");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeHost(skillId: string, hostId: string) {
|
||||
try {
|
||||
await revokeCloudSkillEntitlement(skillId, hostId);
|
||||
await loadEntitlements(skills.value.find((s) => s.id === skillId)!);
|
||||
} catch (error) {
|
||||
showError(error, "failed to revoke entitlement");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInventory() {
|
||||
const hostId = inventoryHostId.value.trim();
|
||||
if (!hostId) return;
|
||||
inventoryLoading.value = true;
|
||||
try {
|
||||
inventory.value = await getHostSkillInventory(hostId);
|
||||
} catch (error) {
|
||||
showError(error, "failed to load host inventory");
|
||||
} finally {
|
||||
inventoryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="skills-view">
|
||||
<header class="row">
|
||||
<h2>Skills</h2>
|
||||
<button :disabled="loading" @click="refresh">
|
||||
<RefreshCw :size="14" /> Refresh
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="success">{{ successMessage }}</p>
|
||||
|
||||
<form v-if="canAdmin" class="skill-form" @submit.prevent="saveSkill">
|
||||
<h3>{{ editingId ? "Edit skill" : "New skill" }}</h3>
|
||||
<label>name <input v-model="form.name" /></label>
|
||||
<label>kind
|
||||
<select v-model="form.kind">
|
||||
<option value="knowledge">knowledge</option>
|
||||
<option value="flow_template">flow_template</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>description <input v-model="form.description" /></label>
|
||||
<label>tags (comma-separated) <input v-model="tagsInput" /></label>
|
||||
<label v-if="form.kind === 'knowledge'">content
|
||||
<textarea v-model="form.content" rows="4"></textarea>
|
||||
</label>
|
||||
<template v-else>
|
||||
<label>steps (JSON)
|
||||
<textarea v-model="stepsJson" rows="4"></textarea>
|
||||
</label>
|
||||
<label>parameters (JSON)
|
||||
<textarea v-model="parametersJson" rows="4"></textarea>
|
||||
</label>
|
||||
</template>
|
||||
<p v-if="formError" class="error">{{ formError }}</p>
|
||||
<div class="row">
|
||||
<button type="submit"><Plus :size="14" /> {{ editingId ? "Save" : "Create" }}</button>
|
||||
<button type="button" @click="resetForm"><X :size="14" /> Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<LoaderCircle v-if="loading" class="spin" :size="20" />
|
||||
<ul v-else class="skill-list">
|
||||
<li v-for="skill in skills" :key="skill.id">
|
||||
<div class="skill-head">
|
||||
<strong>{{ skill.name }}</strong>
|
||||
<span class="badge">{{ skill.kind }}</span>
|
||||
<span class="muted">rev {{ skill.revision }}</span>
|
||||
<div class="row">
|
||||
<button v-if="canAdmin" @click="editSkill(skill)"><Pencil :size="12" /> edit</button>
|
||||
<button v-if="canAdmin" @click="removeSkill(skill)"><Trash2 :size="12" /> delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">{{ skill.description }}</p>
|
||||
<div class="entitlements">
|
||||
<span>entitled hosts:</span>
|
||||
<span v-for="host in entitlementsFor[skill.id] || []" :key="host" class="chip">
|
||||
{{ host }}
|
||||
<button v-if="canAdmin" @click="removeHost(skill.id, host)"><X :size="10" /></button>
|
||||
</span>
|
||||
<template v-if="canAdmin">
|
||||
<input
|
||||
v-model="entitlementHostInput[skill.id]"
|
||||
placeholder="host id"
|
||||
@keyup.enter="addHost(skill.id)"
|
||||
/>
|
||||
<button @click="addHost(skill.id)">grant</button>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<section class="inventory">
|
||||
<h3>Host local-skill inventory</h3>
|
||||
<div class="row">
|
||||
<input v-model="inventoryHostId" placeholder="host id" @keyup.enter="loadInventory" />
|
||||
<button :disabled="inventoryLoading" @click="loadInventory">view</button>
|
||||
</div>
|
||||
<p v-if="inventory && inventory.payload.length === 0" class="muted">no local skills reported</p>
|
||||
<ul v-if="inventory && inventory.payload.length">
|
||||
<li v-for="(item, idx) in inventory.payload" :key="idx">
|
||||
{{ item.name }} ({{ item.kind }}) — origin {{ item.origin }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="inventory?.reported_at" class="muted">reported {{ inventory.reported_at }}</p>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skills-view { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.row { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.skill-form { display: flex; flex-direction: column; gap: 0.5rem; border: 1px solid var(--border, #ccc); padding: 1rem; border-radius: 6px; }
|
||||
.skill-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; }
|
||||
.skill-list { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.skill-list li { border: 1px solid var(--border, #ccc); padding: 0.75rem; border-radius: 6px; }
|
||||
.skill-head { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.skill-head .row { margin-left: auto; }
|
||||
.badge { font-size: 0.7rem; background: var(--muted-bg, #eee); padding: 0.1rem 0.4rem; border-radius: 4px; }
|
||||
.muted { color: var(--muted, #888); font-size: 0.8rem; }
|
||||
.entitlements { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin-top: 0.5rem; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 0.25rem; background: var(--muted-bg, #eee); padding: 0.1rem 0.4rem; border-radius: 10px; font-size: 0.75rem; }
|
||||
.chip button { border: none; background: none; cursor: pointer; padding: 0; display: flex; }
|
||||
input, select, textarea { padding: 0.3rem; border: 1px solid var(--border, #ccc); border-radius: 4px; }
|
||||
button { display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; }
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.error { color: #c00; } .success { color: #070; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user