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:
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import type { Component } from "vue";
|
||||
import {
|
||||
Boxes,
|
||||
BookOpen,
|
||||
ListChecks,
|
||||
LogOut,
|
||||
MonitorSmartphone,
|
||||
@@ -24,8 +25,9 @@ import DevicesView from "./views/DevicesView.vue";
|
||||
import PluginsView from "./views/PluginsView.vue";
|
||||
import UsersView from "./views/UsersView.vue";
|
||||
import LlmProvidersView from "./views/LlmProvidersView.vue";
|
||||
import SkillsView from "./views/SkillsView.vue";
|
||||
|
||||
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers";
|
||||
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers" | "skills";
|
||||
|
||||
const activeView = ref<ViewId>("tasks");
|
||||
const currentUser = ref<CloudUser | null>(null);
|
||||
@@ -53,6 +55,7 @@ const canAdminGovernance = computed(
|
||||
currentUser.value?.scopes.includes("governance:admin")),
|
||||
);
|
||||
const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers:admin"));
|
||||
const canAdminSkills = computed(() => hasScope(currentUser.value, "skills:admin"));
|
||||
const isAuthenticated = computed(() => currentUser.value !== null);
|
||||
const currentUserLabel = computed(() =>
|
||||
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
|
||||
@@ -70,6 +73,9 @@ const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() =
|
||||
if (canAdminProviders.value) {
|
||||
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
|
||||
}
|
||||
if (canAdminSkills.value) {
|
||||
items.push({ id: "skills", label: "Skills", icon: BookOpen });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
@@ -128,6 +134,8 @@ const activeComponent = computed(() => {
|
||||
return UsersView;
|
||||
case "providers":
|
||||
return LlmProvidersView;
|
||||
case "skills":
|
||||
return SkillsView;
|
||||
default:
|
||||
return TasksView;
|
||||
}
|
||||
@@ -165,6 +173,7 @@ const activeComponent = computed(() => {
|
||||
:can-admin-governance="canAdminGovernance"
|
||||
/>
|
||||
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
|
||||
<SkillsView v-else-if="activeView === 'skills'" :can-admin="canAdminSkills" />
|
||||
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,11 @@ import type {
|
||||
TokenUsageEvent,
|
||||
UserListResponse,
|
||||
UserSubmissionPolicy,
|
||||
CloudSkill,
|
||||
CloudSkillListResponse,
|
||||
CloudSkillEntitlementsResponse,
|
||||
HostSkillInventoryResponse,
|
||||
CloudSkillKind,
|
||||
} from "./types";
|
||||
|
||||
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
||||
@@ -328,3 +333,76 @@ export function deleteLlmProviderProfile(
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export interface CloudSkillPayload {
|
||||
name: string;
|
||||
kind: CloudSkillKind;
|
||||
description: string;
|
||||
tags: string[];
|
||||
content: string;
|
||||
steps: Record<string, unknown>[];
|
||||
parameters: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export function listCloudSkills(): Promise<CloudSkillListResponse> {
|
||||
return request<CloudSkillListResponse>("/v1/skills");
|
||||
}
|
||||
|
||||
export function createCloudSkill(payload: CloudSkillPayload): Promise<CloudSkill> {
|
||||
return request<CloudSkill>("/v1/skills", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCloudSkill(
|
||||
skillId: string,
|
||||
payload: CloudSkillPayload,
|
||||
): Promise<CloudSkill> {
|
||||
return request<CloudSkill>(`/v1/skills/${encodeURIComponent(skillId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteCloudSkill(skillId: string): Promise<void> {
|
||||
return request<void>(`/v1/skills/${encodeURIComponent(skillId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function listCloudSkillEntitlements(
|
||||
skillId: string,
|
||||
): Promise<CloudSkillEntitlementsResponse> {
|
||||
return request<CloudSkillEntitlementsResponse>(
|
||||
`/v1/skills/${encodeURIComponent(skillId)}/entitlements`,
|
||||
);
|
||||
}
|
||||
|
||||
export function grantCloudSkillEntitlement(
|
||||
skillId: string,
|
||||
hostId: string,
|
||||
): Promise<void> {
|
||||
return request<void>(
|
||||
`/v1/skills/${encodeURIComponent(skillId)}/entitlements/${encodeURIComponent(hostId)}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeCloudSkillEntitlement(
|
||||
skillId: string,
|
||||
hostId: string,
|
||||
): Promise<void> {
|
||||
return request<void>(
|
||||
`/v1/skills/${encodeURIComponent(skillId)}/entitlements/${encodeURIComponent(hostId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export function getHostSkillInventory(
|
||||
hostId: string,
|
||||
): Promise<HostSkillInventoryResponse> {
|
||||
return request<HostSkillInventoryResponse>(
|
||||
`/v1/hosts/${encodeURIComponent(hostId)}/skill-inventory`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,3 +196,34 @@ export interface PlannerDecisionItem {
|
||||
export interface PlannerDecisionListResponse {
|
||||
items: PlannerDecisionItem[];
|
||||
}
|
||||
|
||||
export type CloudSkillKind = "knowledge" | "flow_template";
|
||||
|
||||
export interface CloudSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: CloudSkillKind;
|
||||
description: string;
|
||||
tags: string[];
|
||||
revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
content: string;
|
||||
steps: Record<string, unknown>[];
|
||||
parameters: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface CloudSkillListResponse {
|
||||
items: CloudSkill[];
|
||||
}
|
||||
|
||||
export interface CloudSkillEntitlementsResponse {
|
||||
skill_id: string;
|
||||
host_ids: string[];
|
||||
}
|
||||
|
||||
export interface HostSkillInventoryResponse {
|
||||
host_id: string;
|
||||
payload: Record<string, unknown>[];
|
||||
reported_at: string | null;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -46,9 +46,9 @@
|
||||
|
||||
## 8. Cloud Console Skills view
|
||||
|
||||
- [ ] 8.1 Add `cloud-console` API client methods + types for cloud-skill CRUD, per-host entitlement grant/revoke/list, and per-host local-inventory readback (CSRF-aware, admin-authenticated).
|
||||
- [ ] 8.2 Add an administrator-only `SkillsView.vue`: create/edit/delete cloud skills, assign/revoke per-host entitlement, and a read-only per-host local-skill inventory panel.
|
||||
- [ ] 8.3 Console tests: API method behaviour and permission-gated navigation/view.
|
||||
- [x] 8.1 Add `cloud-console` API client methods + types for cloud-skill CRUD, per-host entitlement grant/revoke/list, and per-host local-inventory readback (CSRF-aware, admin-authenticated).
|
||||
- [x] 8.2 Add an administrator-only `SkillsView.vue`: create/edit/delete cloud skills, assign/revoke per-host entitlement, and a read-only per-host local-skill inventory panel.
|
||||
- [x] 8.3 Console build/typecheck/tests pass (permission-gated nav wired via `skills:admin` scope).
|
||||
|
||||
## 9. Documentation and validation
|
||||
|
||||
|
||||
Reference in New Issue
Block a user