feat(ops): 优化运维监控界面组件功能和交互
This commit is contained in:
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
import Select, { type SelectOption } from '@/components/common/Select.vue'
|
import Select, { type SelectOption } from '@/components/common/Select.vue'
|
||||||
|
import { adminAPI } from '@/api'
|
||||||
import { opsAPI } from '@/api/admin/ops'
|
import { opsAPI } from '@/api/admin/ops'
|
||||||
import type { AlertRule, MetricType, Operator } from '../types'
|
import type { AlertRule, MetricType, Operator } from '../types'
|
||||||
import type { OpsSeverity } from '@/api/admin/ops'
|
import type { OpsSeverity } from '@/api/admin/ops'
|
||||||
@@ -31,6 +32,7 @@ async function load() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
load()
|
load()
|
||||||
|
loadGroups()
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortedRules = computed(() => {
|
const sortedRules = computed(() => {
|
||||||
@@ -44,29 +46,214 @@ const draft = ref<AlertRule | null>(null)
|
|||||||
|
|
||||||
type MetricGroup = 'system' | 'group' | 'account'
|
type MetricGroup = 'system' | 'group' | 'account'
|
||||||
|
|
||||||
|
interface MetricDefinition {
|
||||||
|
type: MetricType
|
||||||
|
group: MetricGroup
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
recommendedOperator: Operator
|
||||||
|
recommendedThreshold: number
|
||||||
|
unit?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupMetricTypes = new Set<MetricType>([
|
||||||
|
'group_available_accounts',
|
||||||
|
'group_available_ratio',
|
||||||
|
'group_rate_limit_ratio'
|
||||||
|
])
|
||||||
|
|
||||||
|
function parsePositiveInt(value: unknown): number | null {
|
||||||
|
if (value == null) return null
|
||||||
|
if (typeof value === 'boolean') return null
|
||||||
|
const n = typeof value === 'number' ? value : Number.parseInt(String(value), 10)
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupOptionsBase = ref<SelectOption[]>([])
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
const list = await adminAPI.groups.getAll()
|
||||||
|
groupOptionsBase.value = list.map((g) => ({ value: g.id, label: g.name }))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[OpsAlertRulesCard] Failed to load groups', err)
|
||||||
|
groupOptionsBase.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGroupMetricSelected = computed(() => {
|
||||||
|
const metricType = draft.value?.metric_type
|
||||||
|
return metricType ? groupMetricTypes.has(metricType) : false
|
||||||
|
})
|
||||||
|
|
||||||
|
const draftGroupId = computed<number | null>({
|
||||||
|
get() {
|
||||||
|
return parsePositiveInt(draft.value?.filters?.group_id)
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
if (!draft.value) return
|
||||||
|
if (value == null) {
|
||||||
|
if (!draft.value.filters) return
|
||||||
|
delete draft.value.filters.group_id
|
||||||
|
if (Object.keys(draft.value.filters).length === 0) {
|
||||||
|
delete draft.value.filters
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!draft.value.filters) draft.value.filters = {}
|
||||||
|
draft.value.filters.group_id = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupOptions = computed<SelectOption[]>(() => {
|
||||||
|
if (isGroupMetricSelected.value) return groupOptionsBase.value
|
||||||
|
return [{ value: null, label: t('admin.ops.alertRules.form.allGroups') }, ...groupOptionsBase.value]
|
||||||
|
})
|
||||||
|
|
||||||
const metricDefinitions = computed(() => {
|
const metricDefinitions = computed(() => {
|
||||||
return [
|
return [
|
||||||
// System-level metrics
|
// System-level metrics
|
||||||
{ type: 'success_rate' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.successRate') },
|
{
|
||||||
{ type: 'error_rate' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.errorRate') },
|
type: 'success_rate',
|
||||||
{ type: 'upstream_error_rate' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.upstreamErrorRate') },
|
group: 'system',
|
||||||
{ type: 'p95_latency_ms' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.p95') },
|
label: t('admin.ops.alertRules.metrics.successRate'),
|
||||||
{ type: 'p99_latency_ms' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.p99') },
|
description: t('admin.ops.alertRules.metricDescriptions.successRate'),
|
||||||
{ type: 'cpu_usage_percent' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.cpu') },
|
recommendedOperator: '<',
|
||||||
{ type: 'memory_usage_percent' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.memory') },
|
recommendedThreshold: 99,
|
||||||
{ type: 'concurrency_queue_depth' as MetricType, group: 'system' as const, label: t('admin.ops.alertRules.metrics.queueDepth') },
|
unit: '%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'error_rate',
|
||||||
|
group: 'system',
|
||||||
|
label: t('admin.ops.alertRules.metrics.errorRate'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.errorRate'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 1,
|
||||||
|
unit: '%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'upstream_error_rate',
|
||||||
|
group: 'system',
|
||||||
|
label: t('admin.ops.alertRules.metrics.upstreamErrorRate'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.upstreamErrorRate'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 1,
|
||||||
|
unit: '%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'p95_latency_ms',
|
||||||
|
group: 'system',
|
||||||
|
label: t('admin.ops.alertRules.metrics.p95'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.p95'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 1000,
|
||||||
|
unit: 'ms'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'p99_latency_ms',
|
||||||
|
group: 'system',
|
||||||
|
label: t('admin.ops.alertRules.metrics.p99'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.p99'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 2000,
|
||||||
|
unit: 'ms'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'cpu_usage_percent',
|
||||||
|
group: 'system',
|
||||||
|
label: t('admin.ops.alertRules.metrics.cpu'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.cpu'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 80,
|
||||||
|
unit: '%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'memory_usage_percent',
|
||||||
|
group: 'system',
|
||||||
|
label: t('admin.ops.alertRules.metrics.memory'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.memory'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 80,
|
||||||
|
unit: '%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'concurrency_queue_depth',
|
||||||
|
group: 'system',
|
||||||
|
label: t('admin.ops.alertRules.metrics.queueDepth'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.queueDepth'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 10
|
||||||
|
},
|
||||||
|
|
||||||
// Group-level metrics (requires group_id filter)
|
// Group-level metrics (requires group_id filter)
|
||||||
{ type: 'group_available_accounts' as MetricType, group: 'group' as const, label: t('admin.ops.alertRules.metrics.groupAvailableAccounts') },
|
{
|
||||||
{ type: 'group_available_ratio' as MetricType, group: 'group' as const, label: t('admin.ops.alertRules.metrics.groupAvailableRatio') },
|
type: 'group_available_accounts',
|
||||||
{ type: 'group_rate_limit_ratio' as MetricType, group: 'group' as const, label: t('admin.ops.alertRules.metrics.groupRateLimitRatio') },
|
group: 'group',
|
||||||
|
label: t('admin.ops.alertRules.metrics.groupAvailableAccounts'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.groupAvailableAccounts'),
|
||||||
|
recommendedOperator: '<',
|
||||||
|
recommendedThreshold: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group_available_ratio',
|
||||||
|
group: 'group',
|
||||||
|
label: t('admin.ops.alertRules.metrics.groupAvailableRatio'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.groupAvailableRatio'),
|
||||||
|
recommendedOperator: '<',
|
||||||
|
recommendedThreshold: 50,
|
||||||
|
unit: '%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group_rate_limit_ratio',
|
||||||
|
group: 'group',
|
||||||
|
label: t('admin.ops.alertRules.metrics.groupRateLimitRatio'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.groupRateLimitRatio'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 10,
|
||||||
|
unit: '%'
|
||||||
|
},
|
||||||
|
|
||||||
// Account-level metrics
|
// Account-level metrics
|
||||||
{ type: 'account_rate_limited_count' as MetricType, group: 'account' as const, label: t('admin.ops.alertRules.metrics.accountRateLimitedCount') },
|
{
|
||||||
{ type: 'account_error_count' as MetricType, group: 'account' as const, label: t('admin.ops.alertRules.metrics.accountErrorCount') },
|
type: 'account_rate_limited_count',
|
||||||
{ type: 'account_error_ratio' as MetricType, group: 'account' as const, label: t('admin.ops.alertRules.metrics.accountErrorRatio') },
|
group: 'account',
|
||||||
{ type: 'overload_account_count' as MetricType, group: 'account' as const, label: t('admin.ops.alertRules.metrics.overloadAccountCount') }
|
label: t('admin.ops.alertRules.metrics.accountRateLimitedCount'),
|
||||||
] satisfies Array<{ type: MetricType; group: MetricGroup; label: string }>
|
description: t('admin.ops.alertRules.metricDescriptions.accountRateLimitedCount'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'account_error_count',
|
||||||
|
group: 'account',
|
||||||
|
label: t('admin.ops.alertRules.metrics.accountErrorCount'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.accountErrorCount'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'account_error_ratio',
|
||||||
|
group: 'account',
|
||||||
|
label: t('admin.ops.alertRules.metrics.accountErrorRatio'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.accountErrorRatio'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 5,
|
||||||
|
unit: '%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'overload_account_count',
|
||||||
|
group: 'account',
|
||||||
|
label: t('admin.ops.alertRules.metrics.overloadAccountCount'),
|
||||||
|
description: t('admin.ops.alertRules.metricDescriptions.overloadAccountCount'),
|
||||||
|
recommendedOperator: '>',
|
||||||
|
recommendedThreshold: 0
|
||||||
|
}
|
||||||
|
] satisfies MetricDefinition[]
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedMetricDefinition = computed(() => {
|
||||||
|
const metricType = draft.value?.metric_type
|
||||||
|
if (!metricType) return null
|
||||||
|
return metricDefinitions.value.find((m) => m.type === metricType) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
const metricOptions = computed(() => {
|
const metricOptions = computed(() => {
|
||||||
@@ -137,6 +324,9 @@ const editorValidation = computed(() => {
|
|||||||
if (!r) return { valid: true, errors }
|
if (!r) return { valid: true, errors }
|
||||||
if (!r.name || !r.name.trim()) errors.push(t('admin.ops.alertRules.validation.nameRequired'))
|
if (!r.name || !r.name.trim()) errors.push(t('admin.ops.alertRules.validation.nameRequired'))
|
||||||
if (!r.metric_type) errors.push(t('admin.ops.alertRules.validation.metricRequired'))
|
if (!r.metric_type) errors.push(t('admin.ops.alertRules.validation.metricRequired'))
|
||||||
|
if (groupMetricTypes.has(r.metric_type) && !parsePositiveInt(r.filters?.group_id)) {
|
||||||
|
errors.push(t('admin.ops.alertRules.validation.groupIdRequired'))
|
||||||
|
}
|
||||||
if (!r.operator) errors.push(t('admin.ops.alertRules.validation.operatorRequired'))
|
if (!r.operator) errors.push(t('admin.ops.alertRules.validation.operatorRequired'))
|
||||||
if (!(typeof r.threshold === 'number' && Number.isFinite(r.threshold)))
|
if (!(typeof r.threshold === 'number' && Number.isFinite(r.threshold)))
|
||||||
errors.push(t('admin.ops.alertRules.validation.thresholdRequired'))
|
errors.push(t('admin.ops.alertRules.validation.thresholdRequired'))
|
||||||
@@ -321,6 +511,18 @@ function cancelDelete() {
|
|||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.ops.alertRules.form.metric') }}</label>
|
<label class="input-label">{{ t('admin.ops.alertRules.form.metric') }}</label>
|
||||||
<Select v-model="draft!.metric_type" :options="metricOptions" />
|
<Select v-model="draft!.metric_type" :options="metricOptions" />
|
||||||
|
<div v-if="selectedMetricDefinition" class="mt-1 space-y-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<p>{{ selectedMetricDefinition.description }}</p>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
t('admin.ops.alertRules.hints.recommended', {
|
||||||
|
operator: selectedMetricDefinition.recommendedOperator,
|
||||||
|
threshold: selectedMetricDefinition.recommendedThreshold,
|
||||||
|
unit: selectedMetricDefinition.unit || ''
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -328,6 +530,23 @@ function cancelDelete() {
|
|||||||
<Select v-model="draft!.operator" :options="operatorOptions" />
|
<Select v-model="draft!.operator" :options="operatorOptions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="input-label">
|
||||||
|
{{ t('admin.ops.alertRules.form.groupId') }}
|
||||||
|
<span v-if="isGroupMetricSelected" class="ml-1 text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
v-model="draftGroupId"
|
||||||
|
:options="groupOptions"
|
||||||
|
searchable
|
||||||
|
:placeholder="t('admin.ops.alertRules.form.groupPlaceholder')"
|
||||||
|
:error="isGroupMetricSelected && !draftGroupId"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ isGroupMetricSelected ? t('admin.ops.alertRules.hints.groupRequired') : t('admin.ops.alertRules.hints.groupOptional') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.ops.alertRules.form.threshold') }}</label>
|
<label class="input-label">{{ t('admin.ops.alertRules.form.threshold') }}</label>
|
||||||
<input v-model.number="draft!.threshold" class="input" type="number" />
|
<input v-model.number="draft!.threshold" class="input" type="number" />
|
||||||
|
|||||||
@@ -239,47 +239,6 @@ const ttftP50Ms = computed(() => overview.value?.ttft?.p50_ms ?? null)
|
|||||||
const ttftAvgMs = computed(() => overview.value?.ttft?.avg_ms ?? null)
|
const ttftAvgMs = computed(() => overview.value?.ttft?.avg_ms ?? null)
|
||||||
const ttftMaxMs = computed(() => overview.value?.ttft?.max_ms ?? null)
|
const ttftMaxMs = computed(() => overview.value?.ttft?.max_ms ?? null)
|
||||||
|
|
||||||
// --- WebSocket status ---
|
|
||||||
|
|
||||||
const wsStatusLabel = computed(() => {
|
|
||||||
switch (props.wsStatus) {
|
|
||||||
case 'connected':
|
|
||||||
return t('admin.ops.realtime.connected')
|
|
||||||
case 'connecting':
|
|
||||||
return t('admin.ops.realtime.connecting')
|
|
||||||
case 'reconnecting':
|
|
||||||
return t('admin.ops.realtime.reconnecting')
|
|
||||||
case 'offline':
|
|
||||||
return t('admin.ops.realtime.offline')
|
|
||||||
case 'closed':
|
|
||||||
default:
|
|
||||||
return t('admin.ops.realtime.closed')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const wsStatusDotClass = computed(() => {
|
|
||||||
switch (props.wsStatus) {
|
|
||||||
case 'connected':
|
|
||||||
return 'bg-green-500'
|
|
||||||
case 'reconnecting':
|
|
||||||
case 'connecting':
|
|
||||||
return 'bg-yellow-500'
|
|
||||||
case 'offline':
|
|
||||||
return 'bg-orange-500'
|
|
||||||
case 'closed':
|
|
||||||
default:
|
|
||||||
return 'bg-gray-400'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const wsReconnectHint = computed(() => {
|
|
||||||
if (props.wsStatus !== 'reconnecting') return ''
|
|
||||||
const delayMs = props.wsReconnectInMs ?? null
|
|
||||||
if (typeof delayMs !== 'number' || !Number.isFinite(delayMs) || delayMs <= 0) return ''
|
|
||||||
const sec = Math.max(1, Math.ceil(delayMs / 1000))
|
|
||||||
return t('admin.ops.realtime.reconnectIn', { seconds: sec })
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Health Score & Diagnosis (primary) ---
|
// --- Health Score & Diagnosis (primary) ---
|
||||||
|
|
||||||
const isSystemIdle = computed(() => {
|
const isSystemIdle = computed(() => {
|
||||||
@@ -662,19 +621,14 @@ function openJobsDetails() {
|
|||||||
|
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{{ t('common.refresh') }}: {{ updatedAtLabel }}</span>
|
<span>{{ t('common.refresh') }}: {{ updatedAtLabel }}</span>
|
||||||
<span>·</span>
|
|
||||||
|
|
||||||
<span class="flex items-center gap-1.5">
|
<template v-if="systemMetrics">
|
||||||
<span class="relative flex h-2 w-2">
|
<span>·</span>
|
||||||
<span
|
<span>
|
||||||
v-if="wsStatus === 'connected'"
|
{{ t('admin.ops.collectedAt') }} {{ formatTimeShort(systemMetrics.created_at) }}
|
||||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
|
({{ t('admin.ops.window') }} {{ systemMetrics.window_minutes }}m)
|
||||||
></span>
|
|
||||||
<span class="relative inline-flex h-2 w-2 rounded-full" :class="wsStatusDotClass"></span>
|
|
||||||
</span>
|
</span>
|
||||||
<span>{{ wsStatusLabel }}</span>
|
</template>
|
||||||
<span v-if="wsReconnectHint" class="text-[11px] text-gray-400">({{ wsReconnectHint }})</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1189,14 +1143,6 @@ function openJobsDetails() {
|
|||||||
|
|
||||||
<!-- Integrated: System health (cards) -->
|
<!-- Integrated: System health (cards) -->
|
||||||
<div v-if="overview" class="mt-2 border-t border-gray-100 pt-4 dark:border-dark-700">
|
<div v-if="overview" class="mt-2 border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<span v-if="systemMetrics">
|
|
||||||
{{ t('admin.ops.collectedAt') }} {{ formatTimeShort(systemMetrics.created_at) }}
|
|
||||||
({{ t('admin.ops.window') }} {{ systemMetrics.window_minutes }}m)
|
|
||||||
</span>
|
|
||||||
<span v-else>{{ t('admin.ops.noSystemMetrics') }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<!-- CPU -->
|
<!-- CPU -->
|
||||||
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
|
||||||
|
|||||||
@@ -150,11 +150,10 @@ const kindBadgeClass = (kind: string) => {
|
|||||||
<template>
|
<template>
|
||||||
<BaseDialog :show="modelValue" :title="props.preset.title || t('admin.ops.requestDetails.title')" width="full" @close="close">
|
<BaseDialog :show="modelValue" :title="props.preset.title || t('admin.ops.requestDetails.title')" width="full" @close="close">
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
{{ t('admin.ops.requestDetails.rangeLabel', { range: rangeLabel }) }}
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
{{ t('admin.ops.requestDetails.rangeLabel', { range: rangeLabel }) }}
|
||||||
|
</div>
|
||||||
<div class="flex justify-end mb-4">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-secondary btn-sm"
|
class="btn btn-secondary btn-sm"
|
||||||
|
|||||||
Reference in New Issue
Block a user