feat(cloud): enforce host governance budgets
Tests / Test passed: 662

This commit is contained in:
2026-07-13 22:56:31 +08:00
parent 2cd314b183
commit b4803f90e6
28 changed files with 1311 additions and 14 deletions
+23 -1
View File
@@ -7,6 +7,7 @@ import {
LogOut,
MonitorSmartphone,
Puzzle,
UsersRound,
} from "@lucide/vue";
import {
AUTH_INVALID_EVENT,
@@ -19,8 +20,9 @@ import PasswordChangeScreen from "./views/PasswordChangeScreen.vue";
import TasksView from "./views/TasksView.vue";
import DevicesView from "./views/DevicesView.vue";
import PluginsView from "./views/PluginsView.vue";
import UsersView from "./views/UsersView.vue";
type ViewId = "tasks" | "devices" | "plugins";
type ViewId = "tasks" | "devices" | "plugins" | "users";
const activeView = ref<ViewId>("tasks");
const currentUser = ref<CloudUser | null>(null);
@@ -37,6 +39,16 @@ const canSubmitTasks = computed(
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("tasks:submit"),
);
const canAdminUsers = computed(
() => Boolean(
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("users:admin")),
);
const canAdminGovernance = computed(
() => Boolean(
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("governance:admin")),
);
const isAuthenticated = computed(() => currentUser.value !== null);
const currentUserLabel = computed(() =>
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
@@ -48,6 +60,9 @@ const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() =
{ id: "devices", label: "Devices", icon: MonitorSmartphone },
{ id: "plugins", label: "Plugins", icon: Puzzle },
];
if (canAdminUsers.value || canAdminGovernance.value) {
items.push({ id: "users", label: "Users & limits", icon: UsersRound });
}
return items;
});
@@ -102,6 +117,8 @@ const activeComponent = computed(() => {
return DevicesView;
case "plugins":
return PluginsView;
case "users":
return UsersView;
default:
return TasksView;
}
@@ -133,6 +150,11 @@ const activeComponent = computed(() => {
</nav>
<main class="app-main">
<PluginsView v-if="activeView === 'plugins'" :can-admin="canAdminPlugins" />
<UsersView
v-else-if="activeView === 'users'"
:can-admin-users="canAdminUsers"
:can-admin-governance="canAdminGovernance"
/>
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
</main>
</div>
+85
View File
@@ -1,6 +1,8 @@
import type {
CloudUser,
DeviceRecord,
HostGovernancePolicy,
HostTokenUsageSummary,
HostRecord,
PluginRecord,
PluginRegistrationPayload,
@@ -8,6 +10,8 @@ import type {
TaskListResponse,
TaskSubmissionPayload,
TaskStatus,
UserListResponse,
UserSubmissionPolicy,
} from "./types";
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
@@ -119,6 +123,87 @@ export function changePassword(
});
}
export function listUsers(): Promise<UserListResponse> {
return request<UserListResponse>("/v1/users?limit=100&offset=0");
}
export function createUser(payload: {
username: string;
display_name: string;
role: "viewer" | "operator" | "admin";
password: string;
}): Promise<CloudUser> {
return request<CloudUser>("/v1/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateUser(
userId: string,
payload: { display_name?: string; role?: "viewer" | "operator" | "admin"; enabled?: boolean },
): Promise<CloudUser> {
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export function resetUserPassword(userId: string, password: string): Promise<CloudUser> {
return request<CloudUser>(`/v1/users/${encodeURIComponent(userId)}/password`, {
method: "POST",
body: JSON.stringify({ password }),
});
}
export function revokeUserSessions(userId: string): Promise<void> {
return request<void>(`/v1/users/${encodeURIComponent(userId)}/sessions`, {
method: "DELETE",
});
}
export function getUserSubmissionPolicy(userId: string): Promise<UserSubmissionPolicy> {
return request<UserSubmissionPolicy>(
`/v1/users/${encodeURIComponent(userId)}/submission-policy`,
);
}
export function updateUserSubmissionPolicy(
userId: string,
payload: Omit<UserSubmissionPolicy, "user_id" | "revision" | "updated_at"> & {
expected_revision?: number;
},
): Promise<UserSubmissionPolicy> {
return request<UserSubmissionPolicy>(
`/v1/users/${encodeURIComponent(userId)}/submission-policy`,
{ method: "PUT", body: JSON.stringify(payload) },
);
}
export function getHostGovernancePolicy(hostId: string): Promise<HostGovernancePolicy> {
return request<HostGovernancePolicy>(
`/v1/hosts/${encodeURIComponent(hostId)}/governance-policy`,
);
}
export function updateHostGovernancePolicy(
hostId: string,
payload: Omit<HostGovernancePolicy, "host_id" | "revision" | "updated_at"> & {
expected_revision?: number;
},
): Promise<HostGovernancePolicy> {
return request<HostGovernancePolicy>(
`/v1/hosts/${encodeURIComponent(hostId)}/governance-policy`,
{ method: "PUT", body: JSON.stringify(payload) },
);
}
export function getHostTokenUsage(hostId: string): Promise<HostTokenUsageSummary> {
return request<HostTokenUsageSummary>(
`/v1/hosts/${encodeURIComponent(hostId)}/token-usage`,
);
}
export function listTasks(options?: {
status?: TaskStatus;
limit?: number;
+13
View File
@@ -319,6 +319,19 @@ tr.row-selected {
grid-column: 1 / -1;
}
.form-grid input:not([type="checkbox"]),
.form-grid select {
width: 100%;
}
.form-grid .actions {
align-self: end;
}
.policy-grid select {
min-height: 92px;
}
.muted {
color: var(--text-muted);
}
+38
View File
@@ -96,3 +96,41 @@ export interface CloudUser {
updated_at: string;
last_login_at: string | null;
}
export interface UserListResponse {
items: CloudUser[];
limit: number;
offset: number;
}
export interface DeviceTarget {
host_id: string;
device_id: string;
}
export interface UserSubmissionPolicy {
user_id: string;
revision: number;
submission_enabled: boolean;
allowed_host_ids: string[] | null;
allowed_device_targets: DeviceTarget[] | null;
updated_at: string;
}
export interface HostGovernancePolicy {
host_id: string;
revision: number;
self_submission_enabled: boolean;
max_active_tasks: number | null;
daily_token_budget: number | null;
updated_at: string;
}
export interface HostTokenUsageSummary {
host_id: string;
usage_day: string;
daily_token_budget: number | null;
used_tokens: number;
reserved_tokens: number;
remaining_tokens: number | null;
}
+380
View File
@@ -0,0 +1,380 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import {
CloudApiError,
createUser,
getHostGovernancePolicy,
getHostTokenUsage,
getUserSubmissionPolicy,
listDevices,
listHosts,
listUsers,
resetUserPassword,
revokeUserSessions,
updateUser,
updateUserSubmissionPolicy,
updateHostGovernancePolicy,
} from "../api";
import type {
CloudUser,
DeviceRecord,
HostRecord,
HostTokenUsageSummary,
UserRole,
} from "../types";
const props = defineProps<{ canAdminUsers: boolean; canAdminGovernance: boolean }>();
const users = ref<CloudUser[]>([]);
const hosts = ref<HostRecord[]>([]);
const devices = ref<DeviceRecord[]>([]);
const selectedUserId = ref("");
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const createUsername = ref("");
const createDisplayName = ref("");
const createRole = ref<UserRole>("operator");
const createPassword = ref("");
const selectedRole = ref<UserRole>("viewer");
const selectedEnabled = ref(true);
const resetPassword = ref("");
const submissionEnabled = ref(true);
const restrictHosts = ref(false);
const allowedHostIds = ref<string[]>([]);
const restrictDevices = ref(false);
const allowedDeviceKeys = ref<string[]>([]);
const policyRevision = ref<number | null>(null);
const selectedHostId = ref("");
const hostPolicyRevision = ref<number | null>(null);
const hostSelfSubmissionEnabled = ref(true);
const hostMaxActiveTasks = ref("");
const hostDailyTokenBudget = ref("");
const hostUsage = ref<HostTokenUsageSummary | null>(null);
const selectedUser = computed(
() => users.value.find((user) => user.id === selectedUserId.value) ?? null,
);
const deviceKey = (device: Pick<DeviceRecord, "host_id" | "device_id">) =>
`${device.host_id}\u0000${device.device_id}`;
function showError(error: unknown, fallback: string) {
successMessage.value = "";
errorMessage.value = error instanceof Error ? error.message : fallback;
}
function selectUser(user: CloudUser) {
selectedUserId.value = user.id;
selectedRole.value = user.role;
selectedEnabled.value = user.enabled;
}
async function refresh() {
loading.value = true;
errorMessage.value = "";
try {
const requests: Promise<unknown>[] = [];
if (props.canAdminUsers) requests.push(listUsers());
if (props.canAdminGovernance) requests.push(listHosts(), listDevices());
const results = await Promise.all(requests);
let index = 0;
if (props.canAdminUsers) {
users.value = (results[index++] as { items: CloudUser[] }).items;
if (!selectedUserId.value && users.value[0]) selectUser(users.value[0]);
}
if (props.canAdminGovernance) {
hosts.value = results[index++] as HostRecord[];
devices.value = results[index++] as DeviceRecord[];
if (!selectedHostId.value && hosts.value[0]) {
selectedHostId.value = hosts.value[0].host_id;
}
}
} catch (error) {
showError(error, "failed to load user administration data");
} finally {
loading.value = false;
}
}
async function loadHostPolicy() {
if (!props.canAdminGovernance || !selectedHostId.value) return;
errorMessage.value = "";
try {
const [policy, usage] = await Promise.all([
getHostGovernancePolicy(selectedHostId.value),
getHostTokenUsage(selectedHostId.value),
]);
hostUsage.value = usage;
hostPolicyRevision.value = policy.revision;
hostSelfSubmissionEnabled.value = policy.self_submission_enabled;
hostMaxActiveTasks.value = policy.max_active_tasks?.toString() ?? "";
hostDailyTokenBudget.value = policy.daily_token_budget?.toString() ?? "";
} catch (error) {
if (error instanceof CloudApiError && error.status === 404) {
hostPolicyRevision.value = null;
hostSelfSubmissionEnabled.value = true;
hostMaxActiveTasks.value = "";
hostDailyTokenBudget.value = "";
hostUsage.value = await getHostTokenUsage(selectedHostId.value);
return;
}
showError(error, "failed to load Host policy");
}
}
async function loadPolicy() {
if (!props.canAdminGovernance || !selectedUserId.value) return;
errorMessage.value = "";
try {
const policy = await getUserSubmissionPolicy(selectedUserId.value);
submissionEnabled.value = policy.submission_enabled;
restrictHosts.value = policy.allowed_host_ids !== null;
allowedHostIds.value = policy.allowed_host_ids ?? [];
restrictDevices.value = policy.allowed_device_targets !== null;
allowedDeviceKeys.value = (policy.allowed_device_targets ?? []).map(deviceKey);
policyRevision.value = policy.revision;
} catch (error) {
if (error instanceof CloudApiError && error.status === 404) {
submissionEnabled.value = true;
restrictHosts.value = false;
allowedHostIds.value = [];
restrictDevices.value = false;
allowedDeviceKeys.value = [];
policyRevision.value = null;
return;
}
showError(error, "failed to load submission policy");
}
}
async function createAccount() {
saving.value = true;
errorMessage.value = "";
try {
const user = await createUser({
username: createUsername.value.trim(),
display_name: createDisplayName.value.trim(),
role: createRole.value,
password: createPassword.value,
});
createUsername.value = "";
createDisplayName.value = "";
successMessage.value = `created ${user.username}`;
await refresh();
selectUser(user);
} catch (error) {
showError(error, "failed to create user");
} finally {
createPassword.value = "";
saving.value = false;
}
}
async function saveUser() {
if (!selectedUser.value) return;
saving.value = true;
try {
await updateUser(selectedUser.value.id, {
role: selectedRole.value,
enabled: selectedEnabled.value,
});
successMessage.value = "user updated";
await refresh();
} catch (error) {
showError(error, "failed to update user");
} finally {
saving.value = false;
}
}
async function resetPasswordForUser() {
if (!selectedUser.value || !resetPassword.value) return;
saving.value = true;
try {
await resetUserPassword(selectedUser.value.id, resetPassword.value);
successMessage.value = "password reset; existing sessions were revoked";
} catch (error) {
showError(error, "failed to reset password");
} finally {
resetPassword.value = "";
saving.value = false;
}
}
async function revokeSessions() {
if (!selectedUser.value) return;
saving.value = true;
try {
await revokeUserSessions(selectedUser.value.id);
successMessage.value = "sessions revoked";
} catch (error) {
showError(error, "failed to revoke sessions");
} finally {
saving.value = false;
}
}
async function savePolicy() {
if (!selectedUserId.value) {
errorMessage.value = "select or enter a user id before saving a policy";
return;
}
saving.value = true;
try {
const selectedDevices = new Set(allowedDeviceKeys.value);
const policy = await updateUserSubmissionPolicy(selectedUserId.value, {
submission_enabled: submissionEnabled.value,
expected_revision: policyRevision.value ?? 0,
allowed_host_ids: restrictHosts.value ? allowedHostIds.value : null,
allowed_device_targets: restrictDevices.value
? devices.value
.filter((device) => selectedDevices.has(deviceKey(device)))
.map((device) => ({ host_id: device.host_id, device_id: device.device_id }))
: null,
});
policyRevision.value = policy.revision;
successMessage.value = `submission policy saved (revision ${policy.revision})`;
} catch (error) {
showError(error, "failed to save submission policy");
} finally {
saving.value = false;
}
}
function parseOptionalPositive(value: string, label: string): number | null {
if (!value.trim()) return null;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`${label} must be a positive integer`);
}
return parsed;
}
async function saveHostPolicy() {
if (!selectedHostId.value) return;
saving.value = true;
try {
const policy = await updateHostGovernancePolicy(selectedHostId.value, {
self_submission_enabled: hostSelfSubmissionEnabled.value,
max_active_tasks: parseOptionalPositive(hostMaxActiveTasks.value, "max active tasks"),
daily_token_budget: parseOptionalPositive(hostDailyTokenBudget.value, "daily token budget"),
expected_revision: hostPolicyRevision.value ?? 0,
});
hostPolicyRevision.value = policy.revision;
hostUsage.value = await getHostTokenUsage(selectedHostId.value);
successMessage.value = `Host policy saved (revision ${policy.revision})`;
} catch (error) {
showError(error, "failed to save Host policy");
} finally {
saving.value = false;
}
}
watch(selectedUserId, () => {
if (selectedUser.value) {
selectedRole.value = selectedUser.value.role;
selectedEnabled.value = selectedUser.value.enabled;
}
void loadPolicy();
});
watch(selectedHostId, () => {
void loadHostPolicy();
});
onMounted(() => void refresh());
</script>
<template>
<div>
<div class="toolbar">
<h2>Users & task limits</h2>
<button :disabled="loading" @click="refresh">Refresh</button>
<span v-if="loading" class="muted">loading</span>
</div>
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
<div v-if="successMessage" class="notice success">{{ successMessage }}</div>
<div v-if="canAdminUsers" class="panel">
<h3>Create user</h3>
<form class="form-grid" @submit.prevent="createAccount">
<label>Username <input v-model="createUsername" required /></label>
<label>Display name <input v-model="createDisplayName" required /></label>
<label>Role <select v-model="createRole"><option value="viewer">Viewer</option><option value="operator">Operator</option><option value="admin">Admin</option></select></label>
<label>Password <input v-model="createPassword" type="password" required /></label>
<div class="field-full actions"><button class="primary" :disabled="saving">Create user</button></div>
</form>
</div>
<div v-if="canAdminUsers" class="panel">
<h3>Accounts</h3>
<table v-if="users.length">
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th></th></tr></thead>
<tbody>
<tr v-for="user in users" :key="user.id" :class="{ 'row-selected': user.id === selectedUserId }">
<td>{{ user.display_name }} <span class="dim">({{ user.username }})</span></td>
<td>{{ user.role }}</td>
<td>{{ user.enabled ? "enabled" : "disabled" }}</td>
<td><button @click="selectUser(user)">Manage</button></td>
</tr>
</tbody>
</table>
<p v-else class="muted">No accounts found.</p>
</div>
<div v-if="canAdminGovernance && !canAdminUsers" class="panel">
<label>User ID <input v-model="selectedUserId" placeholder="Cloud user id" /></label>
<p class="muted">Use an existing user id to manage its task-submission policy.</p>
</div>
<div v-if="selectedUser && canAdminUsers" class="panel">
<h3>Account: {{ selectedUser.username }}</h3>
<div class="form-grid">
<label>Role <select v-model="selectedRole"><option value="viewer">Viewer</option><option value="operator">Operator</option><option value="admin">Admin</option></select></label>
<label><input v-model="selectedEnabled" type="checkbox" /> Enabled</label>
<div class="field-full actions"><button :disabled="saving" @click="saveUser">Save account</button></div>
<label>Password reset <input v-model="resetPassword" type="password" placeholder="New temporary password" /></label>
<div class="actions"><button :disabled="saving || !resetPassword" @click="resetPasswordForUser">Reset password</button><button :disabled="saving" @click="revokeSessions">Revoke sessions</button></div>
</div>
</div>
<div v-if="canAdminGovernance && selectedUserId" class="panel">
<h3>Task-submission policy <span class="dim">{{ policyRevision === null ? "not configured" : `revision ${policyRevision}` }}</span></h3>
<p class="muted">An unrestricted policy allows ordinary scope-authorized submissions. Enabling a restriction requires an explicit target.</p>
<label><input v-model="submissionEnabled" type="checkbox" /> Allow this user to submit tasks</label>
<div class="form-grid policy-grid">
<label><input v-model="restrictHosts" type="checkbox" /> Restrict Hosts</label>
<select v-model="allowedHostIds" multiple :disabled="!restrictHosts">
<option v-for="host in hosts" :key="host.host_id" :value="host.host_id">{{ host.host_id }}</option>
</select>
<label><input v-model="restrictDevices" type="checkbox" /> Restrict Devices</label>
<select v-model="allowedDeviceKeys" multiple :disabled="!restrictDevices">
<option v-for="device in devices" :key="deviceKey(device)" :value="deviceKey(device)">{{ device.host_id }} / {{ device.device_id }}</option>
</select>
</div>
<div class="actions"><button class="primary" :disabled="saving" @click="savePolicy">Save policy</button></div>
</div>
<div v-if="canAdminGovernance" class="panel">
<h3>Host operational limits</h3>
<p class="muted">Daily token budgets apply when this Host uses Cloud planner transport. Each decision reserves the Cloud-configured conservative ceiling before calling a provider, so a usable budget must cover that ceiling. Direct provider transport remains unmetered until it is moved behind the proxy.</p>
<div class="form-grid">
<label>Host
<select v-model="selectedHostId">
<option v-for="host in hosts" :key="host.host_id" :value="host.host_id">{{ host.host_id }}</option>
</select>
</label>
<label><input v-model="hostSelfSubmissionEnabled" type="checkbox" /> Allow edge self-submission</label>
<label>Maximum active tasks <input v-model="hostMaxActiveTasks" inputmode="numeric" placeholder="Unlimited" /></label>
<label>Daily Cloud-proxy token budget <input v-model="hostDailyTokenBudget" inputmode="numeric" placeholder="Unlimited" /></label>
</div>
<div class="actions"><button class="primary" :disabled="saving || !selectedHostId" @click="saveHostPolicy">Save Host policy {{ hostPolicyRevision === null ? "" : `(revision ${hostPolicyRevision})` }}</button></div>
<p v-if="hostUsage" class="muted">
{{ hostUsage.usage_day }}: used {{ hostUsage.used_tokens }}, reserved {{ hostUsage.reserved_tokens }},
remaining {{ hostUsage.remaining_tokens ?? "unmetered" }} tokens.
</p>
</div>
</div>
</template>