Admins often recreate monitors with the same endpoint, model, and request settings. A server-side duplicate keeps the stored API key out of the browser, creates a disabled copy for review, and uses stable operation identity to recover ambiguous retries without creating extra rows. Constraint: Stored monitor API keys must never be returned to the browser Constraint: Applying a request template must preserve internal duplicate recovery metadata Rejected: Rebuild the monitor from list data | list responses only contain a masked API key Rejected: Copy runtime state and history | a duplicate should start as an unverified configuration Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep duplicated monitors disabled until an administrator reviews and enables them Tested: Go unit tests for repository, service, and admin handler; integration-tag compile; go vet; golangci-lint v2.9; frontend Vitest, ESLint, typecheck, production build; Playwright duplicate flow Not-tested: PostgreSQL container integration locally because Docker is unavailable; CI will execute the database-backed suite
66 lines
2.6 KiB
Vue
66 lines
2.6 KiB
Vue
<template>
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
@click="$emit('run', row)"
|
|
:disabled="running"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
|
>
|
|
<Icon name="refresh" size="sm" :class="running ? 'animate-spin' : ''" />
|
|
<span class="text-xs">{{ t('admin.channelMonitor.runNow') }}</span>
|
|
</button>
|
|
<button
|
|
data-testid="monitor-duplicate"
|
|
:title="duplicateTitle"
|
|
:disabled="duplicating || Boolean(row.api_key_decrypt_failed)"
|
|
@click="$emit('duplicate', row)"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
|
>
|
|
<Icon name="copy" size="sm" />
|
|
<span class="text-xs">
|
|
{{ duplicating ? t('admin.channelMonitor.duplicating') : t('admin.channelMonitor.duplicate') }}
|
|
</span>
|
|
</button>
|
|
<button
|
|
@click="$emit('edit', row)"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
|
>
|
|
<Icon name="edit" size="sm" />
|
|
<span class="text-xs">{{ t('common.edit') }}</span>
|
|
</button>
|
|
<button
|
|
@click="$emit('delete', row)"
|
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
|
>
|
|
<Icon name="trash" size="sm" />
|
|
<span class="text-xs">{{ t('common.delete') }}</span>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
|
|
import Icon from '@/components/icons/Icon.vue'
|
|
|
|
const props = defineProps<{
|
|
row: ChannelMonitor
|
|
running: boolean
|
|
duplicating: boolean
|
|
}>()
|
|
|
|
defineEmits<{
|
|
(e: 'run', row: ChannelMonitor): void
|
|
(e: 'duplicate', row: ChannelMonitor): void
|
|
(e: 'edit', row: ChannelMonitor): void
|
|
(e: 'delete', row: ChannelMonitor): void
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const duplicateTitle = computed(() => {
|
|
if (props.row.api_key_decrypt_failed) return t('admin.channelMonitor.duplicateKeyUnavailable')
|
|
if (props.duplicating) return t('admin.channelMonitor.duplicating')
|
|
return t('admin.channelMonitor.duplicate')
|
|
})
|
|
</script>
|