Tests / Test passed: 855
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>
310 lines
10 KiB
Vue
310 lines
10 KiB
Vue
<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>
|