267 lines
8.1 KiB
Vue
267 lines
8.1 KiB
Vue
|
|
<script setup>
|
||
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
|
|
|
||
|
|
import MetricTile from '../components/MetricTile.vue'
|
||
|
|
import PageHero from '../components/PageHero.vue'
|
||
|
|
import {
|
||
|
|
banBinding,
|
||
|
|
fetchBindings,
|
||
|
|
humanizeError,
|
||
|
|
unbanBinding,
|
||
|
|
unbindBinding,
|
||
|
|
updateBindingIp,
|
||
|
|
} from '../api'
|
||
|
|
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
|
||
|
|
|
||
|
|
const loading = ref(false)
|
||
|
|
const dialogVisible = ref(false)
|
||
|
|
const rows = ref([])
|
||
|
|
const total = ref(0)
|
||
|
|
const filters = reactive({
|
||
|
|
token_suffix: '',
|
||
|
|
ip: '',
|
||
|
|
status: '',
|
||
|
|
page: 1,
|
||
|
|
page_size: 20,
|
||
|
|
})
|
||
|
|
const form = reactive({
|
||
|
|
id: null,
|
||
|
|
bound_ip: '',
|
||
|
|
})
|
||
|
|
|
||
|
|
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
|
||
|
|
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length)
|
||
|
|
const visibleProtectedRate = computed(() => {
|
||
|
|
if (!rows.value.length) {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
return activeCount.value / rows.value.length
|
||
|
|
})
|
||
|
|
|
||
|
|
function requestParams() {
|
||
|
|
return {
|
||
|
|
page: filters.page,
|
||
|
|
page_size: filters.page_size,
|
||
|
|
token_suffix: filters.token_suffix || undefined,
|
||
|
|
ip: filters.ip || undefined,
|
||
|
|
status: filters.status || undefined,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function loadBindings() {
|
||
|
|
loading.value = true
|
||
|
|
try {
|
||
|
|
const data = await fetchBindings(requestParams())
|
||
|
|
rows.value = data.items
|
||
|
|
total.value = data.total
|
||
|
|
} catch (error) {
|
||
|
|
ElMessage.error(humanizeError(error, 'Failed to load bindings.'))
|
||
|
|
} finally {
|
||
|
|
loading.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function resetFilters() {
|
||
|
|
filters.token_suffix = ''
|
||
|
|
filters.ip = ''
|
||
|
|
filters.status = ''
|
||
|
|
filters.page = 1
|
||
|
|
loadBindings()
|
||
|
|
}
|
||
|
|
|
||
|
|
function openEdit(row) {
|
||
|
|
form.id = row.id
|
||
|
|
form.bound_ip = row.bound_ip
|
||
|
|
dialogVisible.value = true
|
||
|
|
}
|
||
|
|
|
||
|
|
async function submitEdit() {
|
||
|
|
if (!form.bound_ip) {
|
||
|
|
ElMessage.warning('Provide a CIDR or single IP.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await updateBindingIp({ id: form.id, bound_ip: form.bound_ip })
|
||
|
|
ElMessage.success('Binding updated.')
|
||
|
|
dialogVisible.value = false
|
||
|
|
await loadBindings()
|
||
|
|
} catch (error) {
|
||
|
|
ElMessage.error(humanizeError(error, 'Failed to update binding.'))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function confirmAction(title, action) {
|
||
|
|
try {
|
||
|
|
await ElMessageBox.confirm(title, 'Confirm action', {
|
||
|
|
confirmButtonText: 'Confirm',
|
||
|
|
cancelButtonText: 'Cancel',
|
||
|
|
type: 'warning',
|
||
|
|
})
|
||
|
|
await action()
|
||
|
|
await loadBindings()
|
||
|
|
} catch (error) {
|
||
|
|
if (error === 'cancel') {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ElMessage.error(humanizeError(error, 'Operation failed.'))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function onPageChange(value) {
|
||
|
|
filters.page = value
|
||
|
|
loadBindings()
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
loadBindings()
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<div class="page-grid">
|
||
|
|
<PageHero
|
||
|
|
eyebrow="Binding control"
|
||
|
|
title="Inspect first-use bindings and intervene without touching proxy workers"
|
||
|
|
description="Edit CIDRs for device changes, remove stale registrations, or move leaked keys into a banned state."
|
||
|
|
>
|
||
|
|
<template #aside>
|
||
|
|
<div class="hero-stat-pair">
|
||
|
|
<div class="hero-stat">
|
||
|
|
<span class="eyebrow">Visible active share</span>
|
||
|
|
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
|
||
|
|
</div>
|
||
|
|
<div class="hero-stat">
|
||
|
|
<span class="eyebrow">Page volume</span>
|
||
|
|
<strong>{{ formatCompactNumber(rows.length) }}</strong>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</PageHero>
|
||
|
|
|
||
|
|
<section class="metric-grid">
|
||
|
|
<MetricTile
|
||
|
|
eyebrow="Visible rows"
|
||
|
|
:value="formatCompactNumber(rows.length)"
|
||
|
|
note="Records loaded on the current page."
|
||
|
|
accent="slate"
|
||
|
|
/>
|
||
|
|
<MetricTile
|
||
|
|
eyebrow="Matching total"
|
||
|
|
:value="formatCompactNumber(total)"
|
||
|
|
note="Bindings matching current filters."
|
||
|
|
accent="mint"
|
||
|
|
/>
|
||
|
|
<MetricTile
|
||
|
|
eyebrow="Active rows"
|
||
|
|
:value="formatCompactNumber(activeCount)"
|
||
|
|
note="Active items visible in the current slice."
|
||
|
|
accent="mint"
|
||
|
|
/>
|
||
|
|
<MetricTile
|
||
|
|
eyebrow="Banned rows"
|
||
|
|
:value="formatCompactNumber(bannedCount)"
|
||
|
|
note="Banned items currently visible in the table."
|
||
|
|
accent="amber"
|
||
|
|
/>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section class="table-card panel">
|
||
|
|
<div class="toolbar">
|
||
|
|
<div class="toolbar-left">
|
||
|
|
<el-input v-model="filters.token_suffix" placeholder="Token suffix" clearable style="width: 180px;" />
|
||
|
|
<el-input v-model="filters.ip" placeholder="Bound IP or CIDR" clearable style="width: 220px;" />
|
||
|
|
<el-select v-model="filters.status" placeholder="Status" clearable style="width: 150px;">
|
||
|
|
<el-option label="Active" :value="1" />
|
||
|
|
<el-option label="Banned" :value="2" />
|
||
|
|
</el-select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="toolbar-right">
|
||
|
|
<el-button @click="resetFilters">Reset</el-button>
|
||
|
|
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadBindings()">Search</el-button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="data-table" style="margin-top: 20px;">
|
||
|
|
<el-table :data="rows" v-loading="loading">
|
||
|
|
<el-table-column prop="id" label="ID" width="90" />
|
||
|
|
<el-table-column prop="token_display" label="Token" min-width="170" />
|
||
|
|
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" />
|
||
|
|
<el-table-column label="Status" width="120">
|
||
|
|
<template #default="{ row }">
|
||
|
|
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round>
|
||
|
|
{{ row.status_label }}
|
||
|
|
</el-tag>
|
||
|
|
</template>
|
||
|
|
</el-table-column>
|
||
|
|
<el-table-column prop="first_used_at" label="First used" min-width="190">
|
||
|
|
<template #default="{ row }">{{ formatDateTime(row.first_used_at) }}</template>
|
||
|
|
</el-table-column>
|
||
|
|
<el-table-column prop="last_used_at" label="Last used" min-width="190">
|
||
|
|
<template #default="{ row }">{{ formatDateTime(row.last_used_at) }}</template>
|
||
|
|
</el-table-column>
|
||
|
|
<el-table-column label="Actions" min-width="280" fixed="right">
|
||
|
|
<template #default="{ row }">
|
||
|
|
<div class="toolbar-left">
|
||
|
|
<el-button size="small" @click="openEdit(row)">Edit IP</el-button>
|
||
|
|
<el-button
|
||
|
|
size="small"
|
||
|
|
type="danger"
|
||
|
|
plain
|
||
|
|
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
|
||
|
|
>
|
||
|
|
Unbind
|
||
|
|
</el-button>
|
||
|
|
<el-button
|
||
|
|
v-if="row.status === 1"
|
||
|
|
size="small"
|
||
|
|
type="warning"
|
||
|
|
plain
|
||
|
|
@click="confirmAction('Ban this token?', () => banBinding(row.id))"
|
||
|
|
>
|
||
|
|
Ban
|
||
|
|
</el-button>
|
||
|
|
<el-button
|
||
|
|
v-else
|
||
|
|
size="small"
|
||
|
|
type="success"
|
||
|
|
plain
|
||
|
|
@click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))"
|
||
|
|
>
|
||
|
|
Unban
|
||
|
|
</el-button>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
</el-table-column>
|
||
|
|
</el-table>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="toolbar" style="margin-top: 18px;">
|
||
|
|
<span class="muted">Page {{ filters.page }} of {{ Math.max(1, Math.ceil(total / filters.page_size)) }}</span>
|
||
|
|
<el-pagination
|
||
|
|
background
|
||
|
|
layout="prev, pager, next"
|
||
|
|
:current-page="filters.page"
|
||
|
|
:page-size="filters.page_size"
|
||
|
|
:total="total"
|
||
|
|
@current-change="onPageChange"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">
|
||
|
|
<el-form label-position="top">
|
||
|
|
<el-form-item label="CIDR or single IP">
|
||
|
|
<el-input v-model="form.bound_ip" placeholder="192.168.1.0/24" />
|
||
|
|
</el-form-item>
|
||
|
|
</el-form>
|
||
|
|
|
||
|
|
<template #footer>
|
||
|
|
<el-button @click="dialogVisible = false">Cancel</el-button>
|
||
|
|
<el-button type="primary" @click="submitEdit">Save</el-button>
|
||
|
|
</template>
|
||
|
|
</el-dialog>
|
||
|
|
</div>
|
||
|
|
</template>
|