* feat(rocketmq): 优化 RocketMQ 消费者组管理和性能监控 - 添加并行订阅组配置收集功能,支持超时控制和并发限制 - 实现消费者滞后监控功能,支持按主题过滤的滞后统计 - 重构消费滞后结果构建逻辑,添加 Broker 客户端映射功能 - 优化 Admin 客户端构建,确保请求超时参数正确传递 - 为 RabbitMQ 面板组件添加 MQ 写入保护确认机制 - 统一 MQ 面板样式表引入,清理重复按钮样式定义 - 增强连接对话框中的生产环境保护逻辑,支持 MQ 类型范围 - 更新测试用例以验证新的消费者滞后计算和客户端映射功能 - 为 RocketMQ 订阅面板添加过滤计数功能和测试覆盖 * feat(rocketmq): 优化 RocketMQ 客户端连接和消费者组管理 - 移除硬编码的 5 秒收集超时,改用连接配置的请求超时 - 添加从第一个可用主节点收集消费者组配置的方法以提高速度 - 重构内部方法访问客户端 API 实现和超时配置 - 在主题过滤模式下保留一个主节点转储以恢复 FIFO 类型准确性 - 改进消费延迟计算,失败时不设置总延迟而是保持未定义 - 为消费者运行信息获取添加 Docker 地址映射支持 - 更新命名服务器探测逻辑以更好地处理高可用配置 - 增加消息队列保活机制的查询超时支持 - 在前端界面中显示部分积压加载失败的错误信息 - 添加卸载主题前的操作确认保护 - 改进空结果集的用户界面提示信息 * fix(rocketmq): 解决消费组延迟统计失败时显示为健康零延迟的问题 - 在 examineConsumeStatsRemapped 中添加成功计数和最后错误记录 - 添加 ensureConsumeStatsProbeSucceeded 方法确保没有主节点响应时抛出异常 - 修改 TopicConsumeDetail 接口使 delay 字段可选以表示探针失败 - 更新 UI 显示逻辑在延迟不可用时显示破折号而不是零 - 添加测试用例验证所有代理失败时的异常处理 - 在 Rust 代码中检查 totalLag 是否存在以避免将缺失值视为零 - 添加消息队列适配器的标识检查防止过期的保活连接被错误清理 * fix(rocketmq): 修复RocketMQ消费组详情加载和统计查询问题 - 为examineConsumeStats调用添加超时参数以支持Docker地址映射 - 更新ensureConsumeStatsProbeSucceeded方法以处理部分失败情况下的空合并结果 - 在消费者组对话框中添加序列号防止陈旧详情/配置响应覆盖新数据 - 修复消费延迟显示逻辑,将null延迟值显示为"-" - 改进错误处理,确保仅在所有代理成功时才接受空偏移表作为有效结果 * fix(rocketmq): 修复消费者组配置收集和消息积压显示问题 - 修改 collectConsumerGroupConfigsForTopicFilter 方法以合并多个主节点的订阅配置 - 优化 Topic-filtered 列表的配置收集逻辑,确保 FIFO 组类型准确性 - 添加 totalLagFailed 标志以正确处理消息积压探测失败情况 - 更新前端组件以在探测失败时显示 "-" 而非 0 - 修复 Docker 环境下多代理地址映射冲突问题 - 添加 RFC1918 私有 IP 验证方法并改进主机可达性判断 - 为积压不可用状态添加数据模型支持 * fix(rocketmq): 修复RocketMQ消费者组配置收集中的FIFO组识别问题 - 重构订阅组配置合并逻辑,确保FIFO组不会被误识别为NORMAL组 - 添加mergeSubscriptionGroupConfigs方法处理重复和冲突的订阅组配置 - 修改collectConsumerGroupConfigsForTopicFilter方法使用新的合并策略 - 添加测试用例验证FIFO组优先级和后续组保持功能 - 修复中间代理返回重复转储时丢失FIFO-only组的问题 * refactor(rocketmq): 优化消费组统计和名称服务器连接逻辑 - 引入 MasterBrokerAddrPlan 类来管理重映射和冲突回退地址 - 将 examineConsumeStatsRemapped 重构为 examineConsumeStatsOnMasters 方法 - 实现对 Docker 环境中重映射地址的更好支持和故障转移处理 - 在连接前预先探测 NameServer 可达性以避免 JVM 启动超时 - 优化消费者组在线成员数显示,未知状态时显示为 '-' - 添加相关单元测试验证地址解析和冲突处理逻辑 * fix(rocketmq): 修复Docker重映射碰撞和消费者组配置问题 - 修复订阅组配置查找逻辑,支持FIFO合并避免顺序消息被隐藏 - 添加超时参数到订阅组查询方法 - 实现Docker重映射碰撞检测和失败关闭机制 - 添加测试验证碰撞空合并失败关闭逻辑 - 修复RocketMQ跟踪详情对话框加载序列控制问题 - 更新消费延迟标签为积压(backlog) - 添加连接缺失和只读写拒绝的国际化消息提示 - 修复CSS样式中按钮主题颜色应用问题 - 优化NameServer探测预算计算逻辑 * fix(rocketmq): 修复消费者组类型分类和成员计数显示问题 - 当订阅配置缺失时返回 UNKNOWN 类型而不是默认 NORMAL - 在删除和更新消费者组时对所有主节点执行操作确保一致性 - 成员计数查询失败时保持字段为空避免显示错误的离线状态 - 添加 UNKNOWN 类型的国际化支持和UI样式适配 - 修改测试用例验证缺失配置时的类型分类逻辑 * style(mq): 移除MQ面板中的主按钮样式覆盖 - 删除了 .mq-btn-primary 和 .btn-primary 的填充颜色样式定义 - 保留了悬停效果样式以维持用户体验一致性 - 更新注释说明MQ Chrome在浅色主题中保持页面表面样式 - 避免与工具栏和表格操作按钮产生视觉冲突 * test(composables): 为 useMqMutationGuard 添加测试并更新实现 - 添加了测试用例验证MQ面板使用的getter连接ID解析 - 将 unref 替换为 toValue 以正确处理getter参数 - 更新类型导入使用 MaybeRefOrGetter 替代 MaybeRef - 添加注释说明使用 toValue 的原因 * fix(rocketmq): 修复消费者连接检查和订阅组变更的容错机制 - 重构 examineConsumerConnectionInfoRemapped 方法以正确处理无主节点响应的情况 - 添加 requireConsumerConnectionProbeResult 方法确保连接探针结果的准确性 - 修改 mutateSubscriptionGroupOnMasters 方法实现全地址覆盖要求防止部分更新 - 新增 ensureSubscriptionGroupMutationSucceeded 和 shouldFailClosedOnCollisionPartialMutation 方法增强订阅组变更的错误处理 - 更新 rocketmq_subscription_from_group 函数默认类型为 UNKNOWN 而非 NORMAL - 添加多个单元测试验证连接探针和订阅组变更的各种异常场景 * test(rocketmq): 为消费者连接探测测试方法添加异常声明 - 在requireConsumerConnectionProbeResultFailsClosedWhenNoMasterAnswers测试方法中添加throws Exception声明 - 确保测试方法能够正确处理可能抛出的异常情况 * fix(i18n): MQ 生产确认 source 本地化并清理 fieldStatus 死键 --------- Co-authored-by: AiLi <aili@aili.com> Co-authored-by: t8y2 <t8y2@users.noreply.github.com> Co-authored-by: skyler <1156263951@qq.com>
825 lines
23 KiB
Vue
825 lines
23 KiB
Vue
<script setup lang="ts">
|
||
import { formatError } from "@/lib/backend/errorUtils";
|
||
import { computed, nextTick, ref, watch } from "vue";
|
||
import { useI18n } from "vue-i18n";
|
||
import type { AuthAction, MqIssuedToken, MqSystemKind, MqTokenRecord, PermissionMap, PolicyScope, TopicInfo } from "@/types/mq";
|
||
import { mqGrantPermission, mqIssueToken, mqListPermissions, mqListTokenRecords, mqRevokePermission } from "@/lib/backend/api";
|
||
import { formatMqTokenIssueError, type MqTokenIssueErrorView } from "@/lib/mq/mqTokenErrors";
|
||
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
|
||
|
||
interface Props {
|
||
connectionId: string;
|
||
tenant?: string;
|
||
namespace?: string;
|
||
topic?: TopicInfo;
|
||
readOnly?: boolean;
|
||
mqSystemKind?: MqSystemKind;
|
||
}
|
||
|
||
const props = defineProps<Props>();
|
||
const { t } = useI18n();
|
||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||
|
||
const actionOptions: AuthAction[] = ["produce", "consume", "functions", "sources", "sinks", "packages"];
|
||
|
||
const isFlatMqCluster = computed(() => props.mqSystemKind === "kafka" || props.mqSystemKind === "rocketmq");
|
||
const isRocketMqCluster = computed(() => props.mqSystemKind === "rocketmq");
|
||
const panelTitle = computed(() => (isRocketMqCluster.value ? t("mqRocketmq.aclTitle") : t("mqPermissions.title")));
|
||
const visibleActionOptions = computed(() => (isFlatMqCluster.value ? (["produce", "consume"] as AuthAction[]) : actionOptions));
|
||
|
||
const permissions = ref<PermissionMap>({});
|
||
const loading = ref(false);
|
||
const error = ref<string>();
|
||
const notice = ref<string>();
|
||
const roleName = ref("");
|
||
const roleNameInput = ref<HTMLInputElement | null>(null);
|
||
const roleNameError = ref("");
|
||
const selectedActions = ref<AuthAction[]>(["produce", "consume"]);
|
||
const actionsError = ref("");
|
||
const tokenRole = ref("");
|
||
const tokenActions = ref<AuthAction[]>([]);
|
||
const tokenExpiresUnlimited = ref(true);
|
||
const tokenExpiresDays = ref(30);
|
||
const tokenNote = ref("");
|
||
const tokenRecords = ref<MqTokenRecord[]>([]);
|
||
const tokenLoading = ref(false);
|
||
const tokenError = ref<string>();
|
||
const tokenIssueError = ref<MqTokenIssueErrorView>();
|
||
const issuedToken = ref<MqIssuedToken>();
|
||
const showTokenDialog = ref(false);
|
||
const readOnlyMessage = computed(() => t("mqPermissions.readOnly"));
|
||
|
||
const scope = computed<PolicyScope | null>(() => {
|
||
if (!props.tenant || !props.namespace) return null;
|
||
if (props.topic) {
|
||
return {
|
||
level: "topic",
|
||
tenant: props.tenant,
|
||
namespace: props.namespace,
|
||
topic: props.topic.shortName,
|
||
persistent: props.topic.persistent,
|
||
};
|
||
}
|
||
return {
|
||
level: "namespace",
|
||
tenant: props.tenant,
|
||
namespace: props.namespace,
|
||
};
|
||
});
|
||
|
||
const scopeLabel = computed(() => {
|
||
const current = scope.value;
|
||
if (!current) return "";
|
||
if (current.level === "topic") {
|
||
return `${current.tenant}/${current.namespace}/${current.topic}`;
|
||
}
|
||
return `${current.tenant}/${current.namespace}`;
|
||
});
|
||
|
||
const permissionRows = computed(() => {
|
||
return Object.entries(permissions.value).map(([role, actions]) => ({ role, actions }));
|
||
});
|
||
|
||
async function guardWritable(operation: string): Promise<boolean> {
|
||
if (props.readOnly) {
|
||
error.value = readOnlyMessage.value;
|
||
notice.value = undefined;
|
||
return false;
|
||
}
|
||
return confirmMqWrite(operation);
|
||
}
|
||
|
||
function formatActionLabel(action: AuthAction): string {
|
||
if (isRocketMqCluster.value) {
|
||
if (action === "produce") return t("mqRocketmq.actionProduce");
|
||
if (action === "consume") return t("mqRocketmq.actionConsume");
|
||
}
|
||
return action;
|
||
}
|
||
|
||
async function loadPermissions() {
|
||
const current = scope.value;
|
||
permissions.value = {};
|
||
notice.value = undefined;
|
||
error.value = undefined;
|
||
if (!current) return;
|
||
|
||
loading.value = true;
|
||
try {
|
||
permissions.value = await mqListPermissions(props.connectionId, current);
|
||
} catch (e: unknown) {
|
||
const msg = formatError(e);
|
||
// Gracefully handle Kafka brokers without an authorizer configured.
|
||
if (msg.includes("SecurityDisabled") || msg.includes("No Authorizer") || msg.includes("authorizer")) {
|
||
error.value = undefined;
|
||
notice.value = t("mqPermissions.kafkaAuthorizerNotConfigured");
|
||
} else {
|
||
error.value = msg;
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function grantPermission() {
|
||
if (!(await guardWritable(t("mqPermissions.grant")))) return;
|
||
const current = scope.value;
|
||
const role = roleName.value.trim();
|
||
roleNameError.value = "";
|
||
actionsError.value = "";
|
||
if (!current) {
|
||
error.value = t("mqPermissions.selectNamespaceOrTopic");
|
||
return;
|
||
}
|
||
if (!role) {
|
||
error.value = undefined;
|
||
roleNameError.value = t("mqPermissions.roleNameRequired");
|
||
await nextTick();
|
||
roleNameInput.value?.focus();
|
||
return;
|
||
}
|
||
if (!selectedActions.value.length) {
|
||
error.value = undefined;
|
||
actionsError.value = t("mqPermissions.selectAtLeastOneAction");
|
||
return;
|
||
}
|
||
|
||
loading.value = true;
|
||
error.value = undefined;
|
||
notice.value = undefined;
|
||
try {
|
||
await mqGrantPermission(props.connectionId, current, role, [...selectedActions.value]);
|
||
notice.value = t("mqPermissions.grantedRole", { role });
|
||
roleName.value = "";
|
||
await loadPermissions();
|
||
} catch (e: unknown) {
|
||
const msg = formatError(e);
|
||
if (msg.includes("SecurityDisabled") || msg.includes("No Authorizer") || msg.includes("authorizer")) {
|
||
error.value = t("mqPermissions.kafkaAuthorizerDisabled");
|
||
} else {
|
||
error.value = msg;
|
||
}
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function revokePermission(role: string) {
|
||
if (!(await guardWritable(t("mqPermissions.revoke")))) return;
|
||
const current = scope.value;
|
||
if (!current) {
|
||
error.value = t("mqPermissions.selectNamespaceOrTopic");
|
||
return;
|
||
}
|
||
if (!confirm(t("mqPermissions.confirmRevoke", { role }))) return;
|
||
|
||
loading.value = true;
|
||
error.value = undefined;
|
||
notice.value = undefined;
|
||
try {
|
||
await mqRevokePermission(props.connectionId, current, role);
|
||
notice.value = t("mqPermissions.revokedRole", { role });
|
||
await loadPermissions();
|
||
} catch (e: unknown) {
|
||
error.value = formatError(e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function openTokenDialog(role: string) {
|
||
const row = permissionRows.value.find((item) => item.role === role);
|
||
tokenRole.value = role;
|
||
tokenActions.value = row?.actions?.length ? [...row.actions] : ["produce", "consume"];
|
||
tokenExpiresUnlimited.value = true;
|
||
tokenExpiresDays.value = 30;
|
||
tokenNote.value = "";
|
||
tokenError.value = undefined;
|
||
tokenIssueError.value = undefined;
|
||
issuedToken.value = undefined;
|
||
tokenRecords.value = [];
|
||
showTokenDialog.value = true;
|
||
void loadTokenRecords();
|
||
}
|
||
|
||
function closeTokenDialog() {
|
||
showTokenDialog.value = false;
|
||
issuedToken.value = undefined;
|
||
}
|
||
|
||
async function loadTokenRecords() {
|
||
if (!tokenRole.value) return;
|
||
tokenLoading.value = true;
|
||
tokenError.value = undefined;
|
||
try {
|
||
tokenRecords.value = await mqListTokenRecords(props.connectionId, tokenRole.value);
|
||
} catch (e: unknown) {
|
||
if (isTokenRecordListUnavailable(e)) {
|
||
tokenRecords.value = [];
|
||
return;
|
||
}
|
||
tokenError.value = formatError(e);
|
||
} finally {
|
||
tokenLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function issueToken() {
|
||
if (props.readOnly) {
|
||
tokenError.value = readOnlyMessage.value;
|
||
tokenIssueError.value = undefined;
|
||
return;
|
||
}
|
||
if (!(await confirmMqWrite(t("mqPermissions.generateToken")))) {
|
||
tokenIssueError.value = undefined;
|
||
return;
|
||
}
|
||
const current = scope.value;
|
||
if (!current) {
|
||
tokenError.value = t("mqPermissions.selectNamespaceOrTopic");
|
||
tokenIssueError.value = undefined;
|
||
return;
|
||
}
|
||
const subject = tokenRole.value.trim();
|
||
if (!subject) {
|
||
tokenError.value = t("mqPermissions.roleNameEmpty");
|
||
tokenIssueError.value = undefined;
|
||
return;
|
||
}
|
||
let expiresInSeconds: number | undefined;
|
||
if (!tokenExpiresUnlimited.value) {
|
||
const days = Number(tokenExpiresDays.value);
|
||
if (!Number.isFinite(days) || days <= 0) {
|
||
tokenError.value = t("mqPermissions.expiryMustBePositive");
|
||
tokenIssueError.value = undefined;
|
||
return;
|
||
}
|
||
expiresInSeconds = Math.round(days * 24 * 60 * 60);
|
||
}
|
||
|
||
tokenLoading.value = true;
|
||
tokenError.value = undefined;
|
||
tokenIssueError.value = undefined;
|
||
issuedToken.value = undefined;
|
||
try {
|
||
issuedToken.value = await mqIssueToken(props.connectionId, {
|
||
subject,
|
||
expiresInSeconds,
|
||
scope: current,
|
||
actions: [...tokenActions.value],
|
||
note: tokenNote.value.trim() || undefined,
|
||
});
|
||
await loadTokenRecords();
|
||
} catch (e: unknown) {
|
||
const formatted = formatMqTokenIssueError(e);
|
||
if (formatted.kind === "missingSigningKey") {
|
||
tokenIssueError.value = formatted;
|
||
tokenError.value = undefined;
|
||
} else {
|
||
tokenError.value = formatted.message;
|
||
}
|
||
} finally {
|
||
tokenLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function copyIssuedToken() {
|
||
if (!issuedToken.value?.token) return;
|
||
await navigator.clipboard?.writeText(issuedToken.value.token);
|
||
}
|
||
|
||
function formatDate(value?: string) {
|
||
if (!value) return t("mqPermissions.unlimited");
|
||
const date = new Date(value);
|
||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||
}
|
||
|
||
function shortFingerprint(value: string) {
|
||
if (value.length <= 22) return value;
|
||
return `${value.slice(0, 14)}...${value.slice(-8)}`;
|
||
}
|
||
|
||
function isTokenRecordListUnavailable(e: unknown) {
|
||
const message = e instanceof Error ? formatError(e) : String(e ?? "");
|
||
return message.includes("/api/mq/tokens/list returned 404");
|
||
}
|
||
|
||
watch(roleName, (value) => {
|
||
if (value.trim()) {
|
||
roleNameError.value = "";
|
||
}
|
||
});
|
||
|
||
watch(selectedActions, (value) => {
|
||
if (value.length) {
|
||
actionsError.value = "";
|
||
}
|
||
});
|
||
|
||
watch(
|
||
() => [props.tenant, props.namespace, props.topic?.name, props.topic?.persistent],
|
||
() => {
|
||
loadPermissions();
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="permissions-panel">
|
||
<div class="panel-toolbar">
|
||
<div>
|
||
<h3>{{ panelTitle }}</h3>
|
||
<div v-if="scopeLabel" class="scope-label">{{ scopeLabel }}</div>
|
||
</div>
|
||
<button @click="loadPermissions" :disabled="loading || !scope" class="btn-secondary">
|
||
{{ loading ? t("mqPermissions.refreshing") : t("mqPermissions.refresh") }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="!scope" class="panel-placeholder">{{ t("mqPermissions.selectNamespaceOrTopic") }}</div>
|
||
|
||
<div v-else class="permissions-content">
|
||
<div v-if="readOnly" class="readonly-hint">{{ t("mqPermissions.readonlyHint") }}</div>
|
||
<div v-if="error" class="panel-error">{{ error }}</div>
|
||
<div v-if="notice" class="panel-notice">{{ notice }}</div>
|
||
|
||
<section class="grant-panel">
|
||
<h4>{{ t("mqPermissions.grantRole") }}</h4>
|
||
<div class="grant-row">
|
||
<label>
|
||
{{ t("mqPermissions.roleName") }}
|
||
<input ref="roleNameInput" v-model="roleName" type="text" :placeholder="t('mqPermissions.roleNamePlaceholder')" :disabled="readOnly" :class="{ invalid: roleNameError }" :aria-invalid="!!roleNameError" />
|
||
<span v-if="roleNameError" class="field-error">{{ roleNameError }}</span>
|
||
</label>
|
||
<div class="actions-group" :class="{ invalid: actionsError }">
|
||
<span>{{ t("mqPermissions.actions") }}</span>
|
||
<label v-for="action in visibleActionOptions" :key="action" class="checkbox-label">
|
||
<input v-model="selectedActions" type="checkbox" :value="action" :disabled="readOnly" />
|
||
{{ formatActionLabel(action) }}
|
||
</label>
|
||
<span v-if="actionsError" class="field-error actions-error">{{ actionsError }}</span>
|
||
</div>
|
||
<button @click="grantPermission" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPermissions.grant") }}</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="permissions-table">
|
||
<h4>{{ t("mqPermissions.currentPermissions") }}</h4>
|
||
<table v-if="permissionRows.length">
|
||
<thead>
|
||
<tr>
|
||
<th>{{ t("mqPermissions.role") }}</th>
|
||
<th>{{ t("mqPermissions.actions") }}</th>
|
||
<th>{{ t("mqPermissions.operations") }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in permissionRows" :key="row.role">
|
||
<td class="role-cell">{{ row.role }}</td>
|
||
<td>
|
||
<span class="tag-list">
|
||
<span v-for="action in row.actions" :key="action" class="tag">{{ formatActionLabel(action) }}</span>
|
||
</span>
|
||
</td>
|
||
<td class="row-actions">
|
||
<button v-if="!isFlatMqCluster" @click="openTokenDialog(row.role)" class="btn-sm">{{ t("mqPermissions.token") }}</button>
|
||
<button @click="revokePermission(row.role)" :disabled="readOnly" class="btn-sm btn-danger">{{ t("mqPermissions.revoke") }}</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div v-else class="empty-state">{{ t("mqPermissions.noPermissions") }}</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div v-if="showTokenDialog" class="dialog-overlay" @click="closeTokenDialog">
|
||
<div class="dialog" @click.stop>
|
||
<div class="dialog-header">
|
||
<h3>{{ t("mqPermissions.clientTokenTitle", { role: tokenRole }) }}</h3>
|
||
<button @click="closeTokenDialog" class="btn-close">×</button>
|
||
</div>
|
||
<div class="dialog-body">
|
||
<div v-if="tokenError" class="panel-error">{{ tokenError }}</div>
|
||
<div v-if="tokenIssueError?.kind === 'missingSigningKey'" class="token-config-error" role="alert">
|
||
<strong>{{ t(tokenIssueError.titleKey) }}</strong>
|
||
<span>{{ t(tokenIssueError.messageKey) }}</span>
|
||
<small>{{ t(tokenIssueError.detailKey) }}</small>
|
||
</div>
|
||
<div v-if="issuedToken" class="issued-token-box">
|
||
<div class="token-warning">{{ t("mqPermissions.tokenShowOnceWarning") }}</div>
|
||
<textarea :value="issuedToken.token" readonly class="token-textarea" />
|
||
<button class="btn-sm" @click="copyIssuedToken">{{ t("mqPermissions.copyToken") }}</button>
|
||
</div>
|
||
|
||
<section class="token-section">
|
||
<h4>{{ t("mqPermissions.issueNewToken") }}</h4>
|
||
<div class="token-form">
|
||
<label>
|
||
{{ t("mqPermissions.role") }}
|
||
<input v-model="tokenRole" type="text" />
|
||
</label>
|
||
<div class="expiry-control">
|
||
<label class="checkbox-label expiry-toggle">
|
||
<input v-model="tokenExpiresUnlimited" type="checkbox" />
|
||
{{ t("mqPermissions.neverExpires") }}
|
||
</label>
|
||
<label :class="{ muted: tokenExpiresUnlimited }">
|
||
{{ t("mqPermissions.expiryDays") }}
|
||
<input v-model.number="tokenExpiresDays" type="number" min="1" step="1" :disabled="tokenExpiresUnlimited" />
|
||
</label>
|
||
</div>
|
||
<div class="actions-group token-actions">
|
||
<span>{{ t("mqPermissions.actions") }}</span>
|
||
<label v-for="action in visibleActionOptions" :key="action" class="checkbox-label">
|
||
<input v-model="tokenActions" type="checkbox" :value="action" />
|
||
{{ formatActionLabel(action) }}
|
||
</label>
|
||
</div>
|
||
<label>
|
||
{{ t("mqPermissions.note") }}
|
||
<input v-model="tokenNote" type="text" :placeholder="t('mqPermissions.notePlaceholder')" />
|
||
</label>
|
||
<button @click="issueToken" :disabled="tokenLoading || readOnly" class="btn-primary">
|
||
{{ tokenLoading ? t("mqPermissions.issuing") : t("mqPermissions.generateToken") }}
|
||
</button>
|
||
</div>
|
||
<p class="token-message">{{ t("mqPermissions.tokenRevokeHint") }}</p>
|
||
</section>
|
||
|
||
<section class="token-section">
|
||
<h4>{{ t("mqPermissions.issueRecords") }}</h4>
|
||
<table v-if="tokenRecords.length">
|
||
<thead>
|
||
<tr>
|
||
<th>{{ t("mqPermissions.time") }}</th>
|
||
<th>{{ t("mqPermissions.algorithm") }}</th>
|
||
<th>{{ t("mqPermissions.expires") }}</th>
|
||
<th>{{ t("mqPermissions.fingerprint") }}</th>
|
||
<th>{{ t("mqPermissions.noteColumn") }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="record in tokenRecords" :key="record.id">
|
||
<td>{{ formatDate(record.createdAt) }}</td>
|
||
<td>{{ record.algorithm.toUpperCase() }}</td>
|
||
<td>{{ formatDate(record.expiresAt) }}</td>
|
||
<td :title="record.tokenFingerprint">{{ shortFingerprint(record.tokenFingerprint) }}</td>
|
||
<td>{{ record.note || "-" }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div v-else class="empty-state">{{ tokenLoading ? t("mqPermissions.loading") : t("mqPermissions.noIssueRecords") }}</div>
|
||
</section>
|
||
</div>
|
||
<div class="dialog-footer">
|
||
<button @click="closeTokenDialog" class="btn-secondary">{{ t("mqPermissions.close") }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
@import "./shared/mqPanel.css";
|
||
|
||
.permissions-panel {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.panel-toolbar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.panel-toolbar h3 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.scope-label {
|
||
margin-top: 4px;
|
||
color: var(--color-text-secondary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.permissions-content {
|
||
flex: 1;
|
||
overflow: auto;
|
||
padding: 16px;
|
||
}
|
||
|
||
.grant-panel,
|
||
.permissions-table {
|
||
padding: 14px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--dbx-radius-fixed-6);
|
||
background: var(--color-background-secondary);
|
||
}
|
||
|
||
.permissions-table {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
h4 {
|
||
margin: 0 0 12px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.grant-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(220px, 1fr) minmax(280px, 2fr) auto;
|
||
gap: 16px;
|
||
align-items: end;
|
||
}
|
||
|
||
label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
color: var(--color-text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
input[type="text"],
|
||
input[type="number"] {
|
||
width: 100%;
|
||
padding: 7px 10px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--dbx-radius-fixed-4);
|
||
background: var(--color-background);
|
||
color: var(--color-text);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
input.invalid {
|
||
border-color: var(--color-error);
|
||
box-shadow: 0 0 0 2px var(--color-error-bg);
|
||
}
|
||
|
||
.field-error {
|
||
color: var(--color-error);
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.actions-group {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px 12px;
|
||
color: var(--color-text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.actions-group.invalid {
|
||
padding: 8px;
|
||
border: 1px solid var(--color-error);
|
||
border-radius: var(--dbx-radius-fixed-6);
|
||
background: var(--color-error-bg);
|
||
}
|
||
|
||
.actions-group > span {
|
||
width: 100%;
|
||
}
|
||
|
||
.actions-error {
|
||
width: 100%;
|
||
}
|
||
|
||
.checkbox-label {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.expiry-control {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.expiry-toggle {
|
||
min-height: 30px;
|
||
}
|
||
|
||
.muted {
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
th,
|
||
td {
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid var(--color-border-light);
|
||
text-align: left;
|
||
}
|
||
|
||
th {
|
||
color: var(--color-text-secondary);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.role-cell {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.row-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tag-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
}
|
||
|
||
.tag {
|
||
padding: 2px 8px;
|
||
border-radius: var(--dbx-radius-fixed-4);
|
||
background: var(--color-primary-alpha);
|
||
color: var(--color-primary);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.empty-state,
|
||
.panel-placeholder {
|
||
padding: 24px;
|
||
color: var(--color-text-secondary);
|
||
text-align: center;
|
||
}
|
||
|
||
.panel-error,
|
||
.panel-notice,
|
||
.readonly-hint {
|
||
padding: 12px 16px;
|
||
margin-bottom: 12px;
|
||
border-radius: var(--dbx-radius-fixed-6);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.panel-error {
|
||
background: var(--color-error-bg);
|
||
color: var(--color-error);
|
||
}
|
||
|
||
.panel-notice {
|
||
background: var(--color-success-alpha);
|
||
color: var(--color-success);
|
||
}
|
||
|
||
.readonly-hint {
|
||
background: var(--color-warning-alpha);
|
||
color: var(--color-warning);
|
||
}
|
||
|
||
.dialog-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
}
|
||
|
||
.dialog {
|
||
width: min(760px, 92vw);
|
||
max-height: 86vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-radius: var(--dbx-radius-fixed-6);
|
||
background: var(--color-background);
|
||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||
}
|
||
|
||
.dialog-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.dialog-header h3 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.dialog-body {
|
||
padding: 18px 20px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.token-message {
|
||
margin: 0;
|
||
color: var(--color-text-secondary);
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.issued-token-box {
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 12px;
|
||
margin-bottom: 16px;
|
||
border: 1px solid var(--color-warning);
|
||
border-radius: var(--dbx-radius-fixed-6);
|
||
background: var(--color-warning-alpha);
|
||
}
|
||
|
||
.token-config-error {
|
||
display: grid;
|
||
gap: 6px;
|
||
padding: 12px 14px;
|
||
margin-bottom: 16px;
|
||
border: 1px solid var(--color-warning);
|
||
border-left-width: 4px;
|
||
border-radius: var(--dbx-radius-fixed-6);
|
||
background: var(--color-warning-alpha);
|
||
color: var(--color-text);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.token-config-error strong {
|
||
color: var(--color-warning);
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.token-config-error small {
|
||
color: var(--color-text-secondary);
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.token-warning {
|
||
color: var(--color-warning);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.token-textarea {
|
||
min-height: 96px;
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--dbx-radius-fixed-4);
|
||
background: var(--color-background);
|
||
color: var(--color-text);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||
font-size: 12px;
|
||
resize: vertical;
|
||
}
|
||
|
||
.token-section {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.token-form {
|
||
display: grid;
|
||
grid-template-columns: minmax(160px, 1fr) minmax(120px, 0.6fr);
|
||
gap: 12px;
|
||
align-items: end;
|
||
}
|
||
|
||
.token-actions {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.dialog-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding: 14px 20px;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
button:disabled,
|
||
input:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
@media (max-width: 800px) {
|
||
.grant-row,
|
||
.token-form {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|