@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NDataTable, NModal, NSelect, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
|
||||
import { listInstances } from '@/api/instances'
|
||||
import { assignReservedIp, createReservedIp, deleteReservedIp, listReservedIps } from '@/api/reservedips'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import type { ReservedIp } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{ cfgId: number | null; region?: string; compartmentId?: string }>()
|
||||
const message = useToast()
|
||||
|
||||
const ips = useAsync<ReservedIp[]>(async () => {
|
||||
if (!props.cfgId) return []
|
||||
try {
|
||||
return await listReservedIps(props.cfgId, props.region, props.compartmentId)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
|
||||
watch([() => props.cfgId, () => props.region, () => props.compartmentId], () => void ips.run(), {
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
const creating = ref(false)
|
||||
|
||||
async function create() {
|
||||
if (!props.cfgId) return
|
||||
creating.value = true
|
||||
try {
|
||||
await createReservedIp(props.cfgId, props.region, undefined, props.compartmentId)
|
||||
message.success('保留 IP 已创建')
|
||||
void ips.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 绑定实例弹窗 ----
|
||||
const showBind = ref(false)
|
||||
const bindTarget = ref<ReservedIp | null>(null)
|
||||
const bindInstanceId = ref<string | null>(null)
|
||||
const binding = ref(false)
|
||||
|
||||
const instances = useAsync(async () => {
|
||||
if (!props.cfgId) return []
|
||||
try {
|
||||
return await listInstances(props.cfgId, props.region, props.compartmentId)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
|
||||
const instanceOptions = computed(() =>
|
||||
(instances.data.value ?? [])
|
||||
.filter((i) => !['TERMINATED', 'TERMINATING'].includes(i.lifecycleState))
|
||||
.map((i) => ({ label: `${i.displayName}(${i.publicIp || '无公网 IP'})`, value: i.id })),
|
||||
)
|
||||
|
||||
function openBind(row: ReservedIp) {
|
||||
bindTarget.value = row
|
||||
bindInstanceId.value = null
|
||||
showBind.value = true
|
||||
void instances.run()
|
||||
}
|
||||
|
||||
async function doBind() {
|
||||
if (!props.cfgId || !bindTarget.value || !bindInstanceId.value) return
|
||||
binding.value = true
|
||||
try {
|
||||
await assignReservedIp(props.cfgId, bindTarget.value.id, bindInstanceId.value, props.region)
|
||||
message.success('已绑定;实例原临时公网 IP 已释放')
|
||||
showBind.value = false
|
||||
void ips.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '绑定失败')
|
||||
} finally {
|
||||
binding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function unbind(row: ReservedIp) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
await assignReservedIp(props.cfgId, row.id, '', props.region)
|
||||
message.success('已解绑,实例暂无公网 IP')
|
||||
void ips.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: ReservedIp) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
await deleteReservedIp(props.cfgId, row.id, props.region)
|
||||
message.success('保留 IP 已删除')
|
||||
void ips.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<ReservedIp>>(() => [
|
||||
{
|
||||
title: 'IP 地址',
|
||||
key: 'ipAddress',
|
||||
minWidth: 140,
|
||||
render: (r) => h('span', { class: 'mono' }, r.ipAddress || '分配中…'),
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
key: 'displayName',
|
||||
minWidth: 130,
|
||||
render: (r) => h('span', { class: 'text-[13px]' }, r.displayName),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'assigned',
|
||||
minWidth: 110,
|
||||
render: (r) =>
|
||||
r.assignedInstanceId
|
||||
? h(StatusBadge, { kind: 'run', label: '已绑定', dot: false })
|
||||
: h(StatusBadge, { kind: 'check', label: '未绑定', dot: false }),
|
||||
},
|
||||
{
|
||||
title: '绑定实例',
|
||||
key: 'assignedInstanceName',
|
||||
minWidth: 150,
|
||||
render: (r) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: 'text-[13px] text-ink-2' },
|
||||
r.assignedInstanceName || (r.assignedInstanceId ? r.assignedInstanceId.slice(-8) : '—'),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'timeCreated',
|
||||
width: 150,
|
||||
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(r.timeCreated)),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
render: (r) =>
|
||||
h('div', { class: 'flex items-center gap-1' }, [
|
||||
r.assignedInstanceId
|
||||
? h(
|
||||
ConfirmPop,
|
||||
{
|
||||
title: '解绑该保留 IP?',
|
||||
content: '解绑后实例将暂无公网 IP,直到重新绑定或分配临时 IP。',
|
||||
kind: 'plain',
|
||||
confirmText: '解绑',
|
||||
onConfirm: () => unbind(r),
|
||||
},
|
||||
{ trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '解绑' }) },
|
||||
)
|
||||
: h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, onClick: () => openBind(r) },
|
||||
{ default: () => '绑定实例' },
|
||||
),
|
||||
h(
|
||||
ConfirmPop,
|
||||
{
|
||||
title: '删除该保留 IP?',
|
||||
content: '地址将归还 Oracle,无法找回。',
|
||||
confirmText: '删除',
|
||||
onConfirm: () => remove(r),
|
||||
},
|
||||
{
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, type: 'error', disabled: !!r.assignedInstanceId },
|
||||
{ default: () => '删除' },
|
||||
),
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">保留 IP</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
区域级固定公网 IP;换绑会先释放实例原临时 IP,创建实例时也可直接选用
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="small" type="primary" :loading="creating" @click="create">创建保留 IP</NButton>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="ips.data.value ?? []"
|
||||
:loading="ips.loading.value"
|
||||
:scroll-x="820"
|
||||
:row-key="(r: ReservedIp) => r.id"
|
||||
/>
|
||||
|
||||
<NModal
|
||||
v-model:show="showBind"
|
||||
preset="card"
|
||||
title="绑定保留 IP 到实例"
|
||||
:style="{ width: '420px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-[13px] text-ink-2">
|
||||
将 <span class="mono">{{ bindTarget?.ipAddress }}</span> 绑定到实例主网卡;
|
||||
实例现有临时公网 IP 会被释放,公网地址变更为该保留 IP。
|
||||
</div>
|
||||
<NSelect
|
||||
v-model:value="bindInstanceId"
|
||||
:options="instanceOptions"
|
||||
:loading="instances.loading.value"
|
||||
placeholder="选择实例"
|
||||
filterable
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NButton size="small" @click="showBind = false">取消</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!bindInstanceId"
|
||||
:loading="binding"
|
||||
@click="doBind"
|
||||
>
|
||||
绑定
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import { updateSecurityList } from '@/api/networks'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import type { SecurityList, SecurityRule } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{
|
||||
cfgId: number
|
||||
list: SecurityList
|
||||
region?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ updated: [] }>()
|
||||
|
||||
const message = useToast()
|
||||
|
||||
type Dir = 'ingress' | 'egress'
|
||||
|
||||
const PROTOCOLS = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: 'TCP', value: '6' },
|
||||
{ label: 'UDP', value: '17' },
|
||||
{ label: 'ICMP', value: '1' },
|
||||
{ label: 'ICMPv6', value: '58' },
|
||||
]
|
||||
|
||||
function protoLabel(p: string): string {
|
||||
return PROTOCOLS.find((o) => o.value === p)?.label ?? p
|
||||
}
|
||||
|
||||
function portText(r: SecurityRule): string {
|
||||
if (r.protocol !== '6' && r.protocol !== '17') return '—'
|
||||
if (!r.portMin) return '全部'
|
||||
return r.portMax && r.portMax !== r.portMin ? `${r.portMin}-${r.portMax}` : String(r.portMin)
|
||||
}
|
||||
|
||||
/** 编辑缓冲;idx 为 -1 表示新增行 */
|
||||
const editing = ref<{ dir: Dir; idx: number } | null>(null)
|
||||
const draft = reactive<SecurityRule>({ protocol: '6', isStateless: false })
|
||||
const saving = ref(false)
|
||||
|
||||
function rulesOf(dir: Dir): SecurityRule[] {
|
||||
return dir === 'ingress' ? (props.list.ingressRules ?? []) : (props.list.egressRules ?? [])
|
||||
}
|
||||
|
||||
function startEdit(dir: Dir, idx: number) {
|
||||
const src = idx >= 0 ? rulesOf(dir)[idx] : undefined
|
||||
Object.assign(draft, {
|
||||
protocol: src?.protocol ?? '6',
|
||||
source: src?.source,
|
||||
destination: src?.destination,
|
||||
isStateless: src?.isStateless ?? false,
|
||||
portMin: src?.portMin,
|
||||
portMax: src?.portMax,
|
||||
description: src?.description,
|
||||
})
|
||||
if (!src) {
|
||||
if (dir === 'ingress') draft.source = '0.0.0.0/0'
|
||||
else draft.destination = '0.0.0.0/0'
|
||||
}
|
||||
editing.value = { dir, idx }
|
||||
}
|
||||
|
||||
function buildDraft(dir: Dir): SecurityRule {
|
||||
const r: SecurityRule = { protocol: draft.protocol, isStateless: false }
|
||||
if (dir === 'ingress') r.source = draft.source?.trim()
|
||||
else r.destination = draft.destination?.trim()
|
||||
if (draft.protocol === '6' || draft.protocol === '17') {
|
||||
if (draft.portMin) {
|
||||
r.portMin = draft.portMin
|
||||
r.portMax = draft.portMax || draft.portMin
|
||||
}
|
||||
}
|
||||
if (draft.description?.trim()) r.description = draft.description.trim()
|
||||
return r
|
||||
}
|
||||
|
||||
/** 整表替换提交:规则字段一经提供即全量覆盖,两方向都要带上 */
|
||||
async function submit(ingress: SecurityRule[], egress: SecurityRule[]) {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateSecurityList(props.cfgId, props.list.id, {
|
||||
region: props.region,
|
||||
ingressRules: ingress,
|
||||
egressRules: egress,
|
||||
})
|
||||
message.success('规则已保存')
|
||||
editing.value = null
|
||||
emit('updated')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function saveEditing() {
|
||||
const ed = editing.value
|
||||
if (!ed) return
|
||||
const cidr = ed.dir === 'ingress' ? draft.source : draft.destination
|
||||
if (!cidr?.trim()) {
|
||||
message.warning('CIDR 不能为空')
|
||||
return
|
||||
}
|
||||
const next = rulesOf(ed.dir).slice()
|
||||
const rule = buildDraft(ed.dir)
|
||||
if (ed.idx >= 0) next[ed.idx] = rule
|
||||
else next.push(rule)
|
||||
const ing = ed.dir === 'ingress' ? next : (props.list.ingressRules ?? [])
|
||||
const eg = ed.dir === 'egress' ? next : (props.list.egressRules ?? [])
|
||||
void submit(ing, eg)
|
||||
}
|
||||
|
||||
function removeRule(dir: Dir, idx: number): Promise<void> {
|
||||
const next = rulesOf(dir).filter((_, i) => i !== idx)
|
||||
const ing = dir === 'ingress' ? next : (props.list.ingressRules ?? [])
|
||||
const eg = dir === 'egress' ? next : (props.list.egressRules ?? [])
|
||||
return submit(ing, eg)
|
||||
}
|
||||
|
||||
function isEditing(dir: Dir, idx: number): boolean {
|
||||
return editing.value?.dir === dir && editing.value.idx === idx
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 py-1">
|
||||
<div v-for="dir in ['ingress', 'egress'] as const" :key="dir">
|
||||
<div class="mb-1.5 text-[11.5px] font-semibold tracking-wide text-ink-3 uppercase">
|
||||
{{ dir === 'ingress' ? '入站规则' : '出站规则' }}
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-lg border border-line-soft bg-white">
|
||||
<table class="w-full border-collapse text-[12.5px]">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-ink-3">
|
||||
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-28">协议</th>
|
||||
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-44">
|
||||
{{ dir === 'ingress' ? '源' : '目标' }}
|
||||
</th>
|
||||
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-36">端口</th>
|
||||
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold">描述</th>
|
||||
<th class="border-b border-line-soft px-3.5 py-1.5 text-right font-semibold w-24">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, idx) in rulesOf(dir)" :key="idx" :class="isEditing(dir, idx) ? 'bg-wash/60' : ''">
|
||||
<template v-if="isEditing(dir, idx)">
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NSelect
|
||||
v-model:value="draft.protocol"
|
||||
size="tiny"
|
||||
:options="PROTOCOLS"
|
||||
class="!w-24"
|
||||
:consistent-menu-width="false"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NInput
|
||||
v-if="dir === 'ingress'"
|
||||
v-model:value="draft.source"
|
||||
size="tiny"
|
||||
placeholder="0.0.0.0/0"
|
||||
/>
|
||||
<NInput v-else v-model:value="draft.destination" size="tiny" placeholder="0.0.0.0/0" />
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<div
|
||||
v-if="draft.protocol === '6' || draft.protocol === '17'"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<NInputNumber
|
||||
v-model:value="draft.portMin"
|
||||
size="tiny"
|
||||
:show-button="false"
|
||||
placeholder="起"
|
||||
class="!w-16"
|
||||
/>
|
||||
<span class="text-ink-3">-</span>
|
||||
<NInputNumber
|
||||
v-model:value="draft.portMax"
|
||||
size="tiny"
|
||||
:show-button="false"
|
||||
placeholder="止"
|
||||
class="!w-16"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-ink-3">—</span>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NInput v-model:value="draft.description" size="tiny" placeholder="描述(可选)" />
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
|
||||
<NButton size="tiny" type="primary" :loading="saving" @click="saveEditing">保存</NButton>
|
||||
<NButton size="tiny" quaternary class="ml-1" :disabled="saving" @click="editing = null">
|
||||
取消
|
||||
</NButton>
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">{{ protoLabel(r.protocol) }}</td>
|
||||
<td class="mono border-b border-line-soft px-3.5 py-1.5">
|
||||
{{ dir === 'ingress' ? r.source : r.destination }}
|
||||
</td>
|
||||
<td class="mono border-b border-line-soft px-3.5 py-1.5">{{ portText(r) }}</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5 text-ink-2">{{ r.description || '—' }}</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
|
||||
<NButton size="tiny" quaternary :disabled="!!editing" @click="startEdit(dir, idx)">
|
||||
编辑
|
||||
</NButton>
|
||||
<ConfirmPop
|
||||
title="删除该条规则?"
|
||||
:content="`${protoLabel(r.protocol)} ${dir === 'ingress' ? r.source : r.destination} ${portText(r)}`"
|
||||
:on-confirm="() => removeRule(dir, idx)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" quaternary type="error" class="ml-1" :disabled="!!editing">
|
||||
删除
|
||||
</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<tr v-if="editing && editing.dir === dir && editing.idx === -1" class="bg-wash/60">
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NSelect
|
||||
v-model:value="draft.protocol"
|
||||
size="tiny"
|
||||
:options="PROTOCOLS"
|
||||
class="!w-24"
|
||||
:consistent-menu-width="false"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NInput
|
||||
v-if="dir === 'ingress'"
|
||||
v-model:value="draft.source"
|
||||
size="tiny"
|
||||
placeholder="0.0.0.0/0"
|
||||
/>
|
||||
<NInput v-else v-model:value="draft.destination" size="tiny" placeholder="0.0.0.0/0" />
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<div
|
||||
v-if="draft.protocol === '6' || draft.protocol === '17'"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<NInputNumber
|
||||
v-model:value="draft.portMin"
|
||||
size="tiny"
|
||||
:show-button="false"
|
||||
placeholder="起"
|
||||
class="!w-16"
|
||||
/>
|
||||
<span class="text-ink-3">-</span>
|
||||
<NInputNumber
|
||||
v-model:value="draft.portMax"
|
||||
size="tiny"
|
||||
:show-button="false"
|
||||
placeholder="止"
|
||||
class="!w-16"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-ink-3">—</span>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NInput v-model:value="draft.description" size="tiny" placeholder="描述(可选)" />
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
|
||||
<NButton size="tiny" type="primary" :loading="saving" @click="saveEditing">保存</NButton>
|
||||
<NButton size="tiny" quaternary class="ml-1" :disabled="saving" @click="editing = null">
|
||||
取消
|
||||
</NButton>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="5" class="px-3.5 py-1.5">
|
||||
<NButton
|
||||
size="tiny"
|
||||
dashed
|
||||
:disabled="!!editing"
|
||||
@click="startEdit(dir, -1)"
|
||||
>
|
||||
+ 添加{{ dir === 'ingress' ? '入站' : '出站' }}规则
|
||||
</NButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SelectOption } from 'naive-ui'
|
||||
import { h, type VNodeChild } from 'vue'
|
||||
|
||||
import type { ReservedIp } from '@/types/api'
|
||||
|
||||
export interface ReservedIpOption extends SelectOption {
|
||||
label: string
|
||||
value: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 未绑定保留 IP → 下拉选项:label 仅 IP(回显紧凑不截断),名称在选项行内弱化展示 */
|
||||
export function freeReservedIpOptions(ips: ReservedIp[]): ReservedIpOption[] {
|
||||
return ips
|
||||
.filter((r) => !r.assignedInstanceId)
|
||||
.map((r) => ({ label: r.ipAddress || r.id.slice(-12), value: r.id, name: r.displayName }))
|
||||
}
|
||||
|
||||
/** NSelect renderLabel:IP 等宽字体 + 名称灰字截断;配合 consistent-menu-width=false 使用 */
|
||||
export function renderReservedIpLabel(option: SelectOption): VNodeChild {
|
||||
const o = option as ReservedIpOption
|
||||
return h('div', { class: 'flex min-w-0 items-baseline gap-2' }, [
|
||||
h('span', { class: 'mono text-[13px]' }, o.label),
|
||||
o.name ? h('span', { class: 'max-w-44 truncate text-xs text-ink-3' }, o.name) : null,
|
||||
])
|
||||
}
|
||||
Reference in New Issue
Block a user