Files
dbx/apps/desktop/src/components/mq/PoliciesPanel.vue
T

420 lines
13 KiB
Vue

<script setup lang="ts">
import { formatError } from "@/lib/backend/errorUtils";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { BacklogQuota, DispatchRate, PolicyScope, PublishRate, RetentionPolicy, SubscribeRate, TopicInfo } from "@/types/mq";
import { mqGetEffectivePolicies, mqSetBacklogQuota, mqSetDispatchRate, mqSetPublishRate, mqSetRetention, mqSetSubscribeRate } from "@/lib/backend/api";
import { defaultMqPolicyForms, policyAccessFromEffectivePolicies, policyFormsFromEffectivePolicies } from "@/lib/mq/mqPolicyForms";
import { useMqMutationGuard } from "@/composables/useMqMutationGuard";
interface Props {
connectionId: string;
tenant?: string;
namespace?: string;
topic?: TopicInfo;
readOnly?: boolean;
isFlatMqCluster?: boolean;
supportsRateLimits?: boolean;
supportsBacklogQuota?: boolean;
supportsRetention?: boolean;
}
const props = defineProps<Props>();
const { t } = useI18n();
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
const policies = ref<unknown>();
const loading = ref(false);
const error = ref<string>();
const notice = ref<string>();
const readOnlyMessage = computed(() => t("mqPolicies.readOnly"));
const defaultForms = defaultMqPolicyForms();
const policyAccess = ref(policyAccessFromEffectivePolicies(undefined));
const publishForm = ref<PublishRate>({ ...defaultForms.publishForm });
const dispatchForm = ref<DispatchRate>({ ...defaultForms.dispatchForm });
const subscribeForm = ref<SubscribeRate>({ ...defaultForms.subscribeForm });
const backlogForm = ref<BacklogQuota>({ ...defaultForms.backlogForm });
const retentionForm = ref<RetentionPolicy>({ ...defaultForms.retentionForm });
const scope = computed<PolicyScope | null>(() => {
if (!props.tenant || !props.namespace) return null;
if (props.isFlatMqCluster && !props.topic) 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 scopePlaceholderMessage = computed(() => (props.isFlatMqCluster ? t("mqPolicies.selectTopicFirst") : t("mqPolicies.selectNamespaceOrTopic")));
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 formattedPolicies = computed(() => JSON.stringify(policies.value ?? {}, null, 2));
async function guardWritable(operation: string): Promise<boolean> {
if (props.readOnly) {
error.value = readOnlyMessage.value;
notice.value = undefined;
return false;
}
return confirmMqWrite(operation);
}
async function loadPolicies() {
const current = scope.value;
policies.value = undefined;
policyAccess.value = policyAccessFromEffectivePolicies(undefined);
notice.value = undefined;
error.value = undefined;
if (!current) return;
loading.value = true;
try {
const loaded = await mqGetEffectivePolicies(props.connectionId, current);
policies.value = loaded;
policyAccess.value = policyAccessFromEffectivePolicies(loaded);
const hydrated = policyFormsFromEffectivePolicies(loaded, defaultMqPolicyForms());
applyPolicyForms(hydrated);
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
async function applyPolicy(kind: string, action: (current: PolicyScope) => Promise<void>) {
if (!(await guardWritable(kind))) return;
const current = scope.value;
if (!current) {
error.value = scopePlaceholderMessage.value;
return;
}
loading.value = true;
error.value = undefined;
notice.value = undefined;
try {
await action(current);
notice.value = t("mqPolicies.savedNotice", { policy: kind });
await loadPolicies();
} catch (e: unknown) {
error.value = formatError(e);
} finally {
loading.value = false;
}
}
function savePublishRate() {
return applyPolicy(t("mqPolicies.publishRate"), (scope) => mqSetPublishRate(props.connectionId, scope, { ...publishForm.value }));
}
function saveDispatchRate() {
return applyPolicy(t("mqPolicies.dispatchRate"), (scope) => mqSetDispatchRate(props.connectionId, scope, { ...dispatchForm.value }));
}
function saveSubscribeRate() {
return applyPolicy(t("mqPolicies.subscribeRate"), (scope) => mqSetSubscribeRate(props.connectionId, scope, { ...subscribeForm.value }));
}
function saveBacklogQuota() {
return applyPolicy(t("mqPolicies.backlogQuota"), (scope) => mqSetBacklogQuota(props.connectionId, scope, { ...backlogForm.value }));
}
function saveRetention() {
return applyPolicy(t("mqPolicies.retention"), (scope) => mqSetRetention(props.connectionId, scope, { ...retentionForm.value }));
}
function currentPolicyForms() {
return {
publishForm: { ...publishForm.value },
dispatchForm: { ...dispatchForm.value },
subscribeForm: { ...subscribeForm.value },
backlogForm: { ...backlogForm.value },
retentionForm: { ...retentionForm.value },
};
}
function applyPolicyForms(forms: ReturnType<typeof currentPolicyForms>) {
publishForm.value = forms.publishForm;
dispatchForm.value = forms.dispatchForm;
subscribeForm.value = forms.subscribeForm;
backlogForm.value = forms.backlogForm;
retentionForm.value = forms.retentionForm;
}
watch(
() => [props.tenant, props.namespace, props.topic?.name, props.topic?.persistent],
() => {
loadPolicies();
},
{ immediate: true },
);
</script>
<template>
<div class="policies-panel">
<div class="panel-toolbar">
<div>
<h3>{{ t("mqPolicies.title") }}</h3>
<div v-if="scopeLabel" class="scope-label">{{ scopeLabel }}</div>
</div>
<button @click="loadPolicies" :disabled="loading || !scope" class="btn-sm">
{{ loading ? t("mqPolicies.refreshing") : t("mqPolicies.refresh") }}
</button>
</div>
<div v-if="!scope" class="panel-placeholder">{{ scopePlaceholderMessage }}</div>
<div v-else class="policies-content">
<div v-if="readOnly" class="readonly-hint">{{ t("mqPolicies.readonlyHint") }}</div>
<div v-if="error" class="panel-error">{{ error }}</div>
<div v-if="notice" class="panel-notice">{{ notice }}</div>
<div v-if="!policyAccess.readable" class="readonly-hint">{{ policyAccess.unsupportedReason }}</div>
<div v-else class="policy-grid">
<section v-if="supportsRateLimits !== false" class="policy-section">
<h4>{{ t("mqPolicies.publishRate") }}</h4>
<label>
{{ t("mqPolicies.msgsPerSecond") }}
<input v-model.number="publishForm.publishThrottlingRateInMsg" type="number" :disabled="readOnly" />
</label>
<label>
{{ t("mqPolicies.bytesPerSecond") }}
<input v-model.number="publishForm.publishThrottlingRateInByte" type="number" :disabled="readOnly" />
</label>
<button @click="savePublishRate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPolicies.save") }}</button>
</section>
<section v-if="supportsRateLimits !== false" class="policy-section">
<h4>{{ t("mqPolicies.dispatchRate") }}</h4>
<label>
{{ t("mqPolicies.msgsPerPeriod") }}
<input v-model.number="dispatchForm.dispatchThrottlingRateInMsg" type="number" :disabled="readOnly" />
</label>
<label>
{{ t("mqPolicies.bytesPerPeriod") }}
<input v-model.number="dispatchForm.dispatchThrottlingRateInByte" type="number" :disabled="readOnly" />
</label>
<label>
{{ t("mqPolicies.periodSeconds") }}
<input v-model.number="dispatchForm.ratePeriodInSecond" type="number" min="1" :disabled="readOnly" />
</label>
<button @click="saveDispatchRate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPolicies.save") }}</button>
</section>
<section v-if="supportsRateLimits !== false" class="policy-section">
<h4>{{ t("mqPolicies.subscribeRate") }}</h4>
<label>
{{ t("mqPolicies.msgsPerConsumerPerPeriod") }}
<input v-model.number="subscribeForm.subscribeThrottlingRatePerConsumer" type="number" :disabled="readOnly" />
</label>
<label>
{{ t("mqPolicies.periodSeconds") }}
<input v-model.number="subscribeForm.ratePeriodInSecond" type="number" min="1" :disabled="readOnly" />
</label>
<button @click="saveSubscribeRate" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPolicies.save") }}</button>
</section>
<section v-if="supportsBacklogQuota !== false" class="policy-section">
<h4>{{ t("mqPolicies.backlogQuota") }}</h4>
<label>
{{ t("mqPolicies.sizeLimitBytes") }}
<input v-model.number="backlogForm.limitSize" type="number" :disabled="readOnly" />
</label>
<label>
{{ t("mqPolicies.timeLimitSeconds") }}
<input v-model.number="backlogForm.limitTime" type="number" :disabled="readOnly" />
</label>
<label>
{{ t("mqPolicies.policy") }}
<select v-model="backlogForm.policy" :disabled="readOnly">
<option value="producer_request_hold">{{ t("mqPolicies.backlogPolicyProducerRequestHold") }}</option>
<option value="producer_exception">{{ t("mqPolicies.backlogPolicyProducerException") }}</option>
<option value="consumer_backlog_eviction">{{ t("mqPolicies.backlogPolicyConsumerBacklogEviction") }}</option>
</select>
</label>
<label>
{{ t("mqPolicies.type") }}
<input v-model="backlogForm.quotaType" type="text" :disabled="readOnly" />
</label>
<button @click="saveBacklogQuota" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPolicies.save") }}</button>
</section>
<section v-if="supportsRetention !== false" class="policy-section">
<h4>{{ t("mqPolicies.retention") }}</h4>
<label>
{{ t("mqPolicies.retentionTimeMinutes") }}
<input v-model.number="retentionForm.retentionTimeInMinutes" type="number" :disabled="readOnly" />
</label>
<label>
{{ t("mqPolicies.retentionSizeMb") }}
<input v-model.number="retentionForm.retentionSizeInMb" type="number" :disabled="readOnly" />
</label>
<button @click="saveRetention" :disabled="loading || readOnly" class="btn-primary">{{ t("mqPolicies.save") }}</button>
</section>
</div>
<section class="json-section">
<h4>{{ t("mqPolicies.effectivePolicies") }}</h4>
<pre>{{ formattedPolicies }}</pre>
</section>
</div>
</div>
</template>
<style scoped>
@import "./shared/mqPanel.css";
.policies-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;
}
.policies-content {
flex: 1;
overflow: auto;
padding: 16px;
}
.policy-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
}
.policy-section,
.json-section {
padding: 14px;
border: 1px solid var(--color-border);
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background-secondary);
}
.policy-section h4,
.json-section h4 {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
color: var(--color-text-secondary);
font-size: 13px;
}
input,
select {
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:disabled,
select:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.json-section {
margin-top: 16px;
}
pre {
margin: 0;
padding: 12px;
max-height: 360px;
overflow: auto;
border-radius: var(--dbx-radius-fixed-6);
background: var(--color-background);
color: var(--color-text);
font-size: 12px;
}
.panel-placeholder,
.panel-error,
.panel-notice,
.readonly-hint {
padding: 12px 16px;
margin-bottom: 12px;
border-radius: var(--dbx-radius-fixed-6);
font-size: 13px;
}
.panel-placeholder {
margin: 24px;
color: var(--color-text-secondary);
text-align: center;
}
.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);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>