414 lines
16 KiB
Vue
414 lines
16 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from "vue";
|
|
import {
|
|
CloudApiError,
|
|
createUser,
|
|
getHostGovernancePolicy,
|
|
getHostTokenUsage,
|
|
listHostTokenUsageEvents,
|
|
getUserSubmissionPolicy,
|
|
listDevices,
|
|
listHosts,
|
|
listUsers,
|
|
resetUserPassword,
|
|
revokeUserSessions,
|
|
updateUser,
|
|
updateUserSubmissionPolicy,
|
|
updateHostGovernancePolicy,
|
|
} from "../api";
|
|
import type {
|
|
CloudUser,
|
|
DeviceRecord,
|
|
HostRecord,
|
|
HostTokenUsageSummary,
|
|
TokenUsageEvent,
|
|
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 hostUsageEvents = ref<TokenUsageEvent[]>([]);
|
|
|
|
const selectedUser = computed(
|
|
() => users.value.find((user) => user.id === selectedUserId.value) ?? null,
|
|
);
|
|
const selectedHost = computed(
|
|
() => hosts.value.find((host) => host.host_id === selectedHostId.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, events] = await Promise.all([
|
|
getHostGovernancePolicy(selectedHostId.value),
|
|
getHostTokenUsage(selectedHostId.value),
|
|
listHostTokenUsageEvents(selectedHostId.value),
|
|
]);
|
|
hostUsage.value = usage;
|
|
hostUsageEvents.value = events;
|
|
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 = "";
|
|
const [usage, events] = await Promise.all([
|
|
getHostTokenUsage(selectedHostId.value),
|
|
listHostTokenUsageEvents(selectedHostId.value),
|
|
]);
|
|
hostUsage.value = usage;
|
|
hostUsageEvents.value = events;
|
|
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;
|
|
const [usage, events] = await Promise.all([
|
|
getHostTokenUsage(selectedHostId.value),
|
|
listHostTokenUsageEvents(selectedHostId.value),
|
|
]);
|
|
hostUsage.value = usage;
|
|
hostUsageEvents.value = events;
|
|
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="selectedHost?.planner_transport === 'direct'" class="muted">
|
|
Direct provider transport: unmetered. Cloud cannot enforce or verify this Host's token budget.
|
|
</p>
|
|
<p v-else-if="hostUsage" class="muted">
|
|
{{ hostUsage.usage_day }}: used {{ hostUsage.used_tokens }}, reserved {{ hostUsage.reserved_tokens }},
|
|
remaining {{ hostUsage.remaining_tokens ?? "unmetered" }} tokens.
|
|
</p>
|
|
<table v-if="hostUsageEvents.length">
|
|
<thead><tr><th>Time</th><th>Provider / model</th><th>Tokens</th><th>Task</th></tr></thead>
|
|
<tbody>
|
|
<tr v-for="event in hostUsageEvents" :key="event.id">
|
|
<td class="dim">{{ new Date(event.occurred_at).toLocaleString() }}</td>
|
|
<td>{{ event.provider }} <span class="dim">{{ event.model }}</span></td>
|
|
<td>{{ event.total_tokens }}</td>
|
|
<td class="dim">{{ event.task_id ?? "local" }}<span v-if="event.attempt"> / #{{ event.attempt }}</span></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p v-else class="muted">No Cloud-proxy usage events recorded for this Host.</p>
|
|
</div>
|
|
</div>
|
|
</template>
|