Files
gpt-load/web/src/features/groups/keys/GroupKeyRow.vue
T

206 lines
6.5 KiB
Vue

<script setup lang="ts">
import { RotateCcw, Trash2 } from '@lucide/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { GroupKeyItemDto } from '@/api/control/types'
import AppButton from '@/components/ui/AppButton.vue'
import AppTooltip from '@/components/ui/AppTooltip.vue'
import DisclosurePanel from '@/components/ui/DisclosurePanel.vue'
import IconButton from '@/components/ui/IconButton.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import { formatLocalInstant } from '@/lib/format'
const props = defineProps<{ item: GroupKeyItemDto; selected: boolean; busy: boolean }>()
const emit = defineEmits<{
'update:selected': [selected: boolean]
weight: [payload: { item: GroupKeyItemDto; value: string }]
toggle: [item: GroupKeyItemDto]
restore: [item: GroupKeyItemDto]
remove: [item: GroupKeyItemDto]
}>()
const { locale, n, t } = useI18n()
const weights = Array.from({ length: 100 }, (_, index) => index + 1)
const isProblem = computed(
() => props.item.effective_status === 'cooldown' || props.item.effective_status === 'blacklisted',
)
const weightValue = computed(() =>
props.item.weight_mode === 'manual' && props.item.weight !== null
? String(props.item.weight)
: 'auto',
)
const weightLabel = computed(() =>
props.item.weight === null
? t('group.keys.notScheduled')
: t(`group.keys.weight.${props.item.weight_mode}`, { weight: n(props.item.weight) }),
)
const recentLabel = computed(() =>
props.item.recent_failure_count === 0
? t('group.keys.recentSuccessOnly', { success: n(props.item.recent_success_count) })
: t('group.keys.recent', {
success: n(props.item.recent_success_count),
failure: n(props.item.recent_failure_count),
}),
)
const recoveryLabel = computed(() => {
if (props.item.recovery.mode === 'none') return t('group.keys.recovery.none')
if (props.item.recovery.at_ms !== null)
return t('group.keys.recovery.at', {
time: formatLocalInstant(props.item.recovery.at_ms, locale.value),
})
return t(`group.keys.recovery.${props.item.recovery.mode}`)
})
function updateWeight(event: Event): void {
emit('weight', { item: props.item, value: (event.target as HTMLSelectElement).value })
}
</script>
<template>
<tr>
<td>
<input
type="checkbox"
:checked="selected"
:disabled="busy"
:aria-label="t('group.keys.selectKey', { mask: item.mask })"
@change="emit('update:selected', ($event.target as HTMLInputElement).checked)"
/>
</td>
<td class="group-key-row__mask">{{ item.mask }}</td>
<td>
<StatusBadge :status="item.effective_status">{{
t(`group.keys.effective.${item.effective_status}`)
}}</StatusBadge>
</td>
<td>
<AppTooltip v-if="item.weight === null" :content="t('group.keys.notScheduledHelp')"
><span class="group-key-row__weight group-key-row__weight--none" tabindex="0">{{
t('group.keys.none')
}}</span></AppTooltip
>
<span v-else class="group-key-row__weight">{{ weightLabel }}</span>
</td>
<td class="group-key-row__recent">{{ recentLabel }}</td>
<td>
<div class="group-key-row__actions">
<label class="sr-only" :for="`key-weight-${item.id}`">{{
t('group.keys.weightFor', { mask: item.mask })
}}</label>
<select
:id="`key-weight-${item.id}`"
:value="weightValue"
:disabled="busy || item.configured_status === 'disabled'"
@change="updateWeight"
>
<option value="auto">{{ t('group.keys.auto') }}</option>
<option v-for="weight in weights" :key="weight" :value="String(weight)">
{{ weight }}
</option>
</select>
<AppButton variant="ghost" size="compact" :disabled="busy" @click="emit('toggle', item)">{{
item.configured_status === 'active' ? t('group.keys.disable') : t('group.keys.enable')
}}</AppButton>
<IconButton
v-if="isProblem"
variant="ghost"
size="compact"
:label="t('group.keys.restore')"
:disabled="busy"
@click="emit('restore', item)"
><RotateCcw :size="15" aria-hidden="true"
/></IconButton>
<IconButton
variant="ghost"
size="compact"
:label="t('group.keys.delete')"
:disabled="busy"
@click="emit('remove', item)"
><Trash2 :size="15" aria-hidden="true"
/></IconButton>
</div>
</td>
</tr>
<tr class="group-key-row__detail">
<td colspan="6">
<DisclosurePanel :summary="t('group.keys.details')"
><dl>
<div>
<dt>{{ t('group.keys.detailsFailure') }}</dt>
<dd>
{{
item.recent_failure_count === 0
? t('group.keys.none')
: `${item.last_failure_category}${item.last_status_code === null ? '' : ` · ${item.last_status_code}`}`
}}
</dd>
</div>
<div>
<dt>{{ t('group.keys.detailsRecovery') }}</dt>
<dd>{{ recoveryLabel }}</dd>
</div>
<div>
<dt>{{ t('group.keys.detailsConsecutive') }}</dt>
<dd>{{ n(item.consecutive_failure_count) }}</dd>
</div>
</dl></DisclosurePanel
>
</td>
</tr>
</template>
<style scoped>
.group-key-row__mask,
.group-key-row__recent,
.group-key-row__weight {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.group-key-row__weight--none {
color: var(--color-text-faint);
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.group-key-row__actions {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
.group-key-row__actions select {
min-height: var(--control-compact);
max-width: 104px;
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
padding-inline: var(--space-1);
font: inherit;
}
.group-key-row__detail td {
padding-top: 0;
}
.group-key-row__detail dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
margin: 0;
}
.group-key-row__detail dl div {
min-width: 0;
}
.group-key-row__detail dt {
color: var(--color-text-faint);
font-size: var(--text-sm);
}
.group-key-row__detail dd {
margin: var(--space-1) 0 0;
overflow-wrap: anywhere;
}
@media (max-width: 1023px) {
.group-key-row__detail dl {
grid-template-columns: 1fr;
}
}
</style>