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 type { Component } from "vue";
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
|
BookOpen,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
LogOut,
|
LogOut,
|
||||||
MonitorSmartphone,
|
MonitorSmartphone,
|
||||||
@@ -24,8 +25,9 @@ import DevicesView from "./views/DevicesView.vue";
|
|||||||
import PluginsView from "./views/PluginsView.vue";
|
import PluginsView from "./views/PluginsView.vue";
|
||||||
import UsersView from "./views/UsersView.vue";
|
import UsersView from "./views/UsersView.vue";
|
||||||
import LlmProvidersView from "./views/LlmProvidersView.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 activeView = ref<ViewId>("tasks");
|
||||||
const currentUser = ref<CloudUser | null>(null);
|
const currentUser = ref<CloudUser | null>(null);
|
||||||
@@ -53,6 +55,7 @@ const canAdminGovernance = computed(
|
|||||||
currentUser.value?.scopes.includes("governance:admin")),
|
currentUser.value?.scopes.includes("governance:admin")),
|
||||||
);
|
);
|
||||||
const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers: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 isAuthenticated = computed(() => currentUser.value !== null);
|
||||||
const currentUserLabel = computed(() =>
|
const currentUserLabel = computed(() =>
|
||||||
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
|
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) {
|
if (canAdminProviders.value) {
|
||||||
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
|
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
|
||||||
}
|
}
|
||||||
|
if (canAdminSkills.value) {
|
||||||
|
items.push({ id: "skills", label: "Skills", icon: BookOpen });
|
||||||
|
}
|
||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,6 +134,8 @@ const activeComponent = computed(() => {
|
|||||||
return UsersView;
|
return UsersView;
|
||||||
case "providers":
|
case "providers":
|
||||||
return LlmProvidersView;
|
return LlmProvidersView;
|
||||||
|
case "skills":
|
||||||
|
return SkillsView;
|
||||||
default:
|
default:
|
||||||
return TasksView;
|
return TasksView;
|
||||||
}
|
}
|
||||||
@@ -165,6 +173,7 @@ const activeComponent = computed(() => {
|
|||||||
:can-admin-governance="canAdminGovernance"
|
:can-admin-governance="canAdminGovernance"
|
||||||
/>
|
/>
|
||||||
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
|
<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" />
|
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import type {
|
|||||||
TokenUsageEvent,
|
TokenUsageEvent,
|
||||||
UserListResponse,
|
UserListResponse,
|
||||||
UserSubmissionPolicy,
|
UserSubmissionPolicy,
|
||||||
|
CloudSkill,
|
||||||
|
CloudSkillListResponse,
|
||||||
|
CloudSkillEntitlementsResponse,
|
||||||
|
HostSkillInventoryResponse,
|
||||||
|
CloudSkillKind,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
|
||||||
@@ -328,3 +333,76 @@ export function deleteLlmProviderProfile(
|
|||||||
{ method: "DELETE" },
|
{ 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 {
|
export interface PlannerDecisionListResponse {
|
||||||
items: PlannerDecisionItem[];
|
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. 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).
|
- [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).
|
||||||
- [ ] 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.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.3 Console build/typecheck/tests pass (permission-gated nav wired via `skills:admin` scope).
|
||||||
|
|
||||||
## 9. Documentation and validation
|
## 9. Documentation and validation
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user