Fix HTTP deployment flow and redesign bindings workspace
This commit is contained in:
@@ -630,6 +630,184 @@ body::before {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.binding-workbench {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.binding-head {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.binding-head-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-width: 64ch;
|
||||
}
|
||||
|
||||
.binding-head-copy p,
|
||||
.binding-head-copy h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.binding-summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.binding-summary-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(242, 250, 247, 0.58));
|
||||
border: 1px solid rgba(9, 22, 30, 0.08);
|
||||
}
|
||||
|
||||
.binding-summary-card strong {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.binding-summary-label {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--sentinel-ink-soft);
|
||||
}
|
||||
|
||||
.binding-summary-card--warn {
|
||||
background: linear-gradient(180deg, rgba(255, 246, 238, 0.86), rgba(255, 239, 225, 0.74));
|
||||
}
|
||||
|
||||
.binding-summary-card--danger {
|
||||
background: linear-gradient(180deg, rgba(255, 240, 241, 0.88), rgba(255, 229, 231, 0.74));
|
||||
}
|
||||
|
||||
.binding-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 0.9fr) minmax(190px, 1.1fr) minmax(140px, 0.7fr) minmax(120px, 0.5fr) auto;
|
||||
gap: 14px;
|
||||
align-items: end;
|
||||
padding: 18px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(9, 29, 41, 0.98), rgba(11, 32, 45, 0.88));
|
||||
}
|
||||
|
||||
.field-page-size {
|
||||
width: min(100%, 140px);
|
||||
}
|
||||
|
||||
.binding-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.binding-filter-grid .filter-label {
|
||||
color: rgba(247, 255, 254, 0.88);
|
||||
}
|
||||
|
||||
.binding-filter-grid .el-input__wrapper,
|
||||
.binding-filter-grid .el-select__wrapper {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.binding-table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.binding-table-note {
|
||||
color: var(--sentinel-ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.binding-table .el-table {
|
||||
--el-table-border-color: rgba(9, 22, 30, 0.08);
|
||||
--el-table-header-bg-color: rgba(8, 31, 45, 0.95);
|
||||
--el-table-row-hover-bg-color: rgba(7, 176, 147, 0.05);
|
||||
--el-table-header-text-color: rgba(247, 255, 254, 0.86);
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.binding-table .el-table th.el-table__cell {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.binding-table .el-table td.el-table__cell {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.binding-row--banned {
|
||||
--el-table-tr-bg-color: rgba(220, 79, 83, 0.06);
|
||||
}
|
||||
|
||||
.binding-row--dormant {
|
||||
--el-table-tr-bg-color: rgba(239, 127, 65, 0.06);
|
||||
}
|
||||
|
||||
.binding-token-cell,
|
||||
.binding-health-cell,
|
||||
.binding-activity-cell {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.binding-token-main,
|
||||
.binding-ip-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.binding-id {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(9, 22, 30, 0.06);
|
||||
color: var(--sentinel-ink-soft);
|
||||
font-size: 0.78rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.binding-ip-cell code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(9, 22, 30, 0.07);
|
||||
color: #08202d;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.binding-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.binding-action-row .el-button {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.form-feedback {
|
||||
margin: 12px 0 0;
|
||||
color: var(--sentinel-danger);
|
||||
@@ -670,6 +848,16 @@ body::before {
|
||||
.hero-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.binding-summary-strip,
|
||||
.binding-filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.binding-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -684,9 +872,24 @@ body::before {
|
||||
.chart-card,
|
||||
.table-card,
|
||||
.form-card,
|
||||
.hero-panel {
|
||||
.hero-panel,
|
||||
.binding-workbench {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.binding-summary-strip,
|
||||
.binding-filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.binding-table-toolbar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.binding-ip-line,
|
||||
.binding-action-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
Connection,
|
||||
CopyDocument,
|
||||
EditPen,
|
||||
Lock,
|
||||
RefreshRight,
|
||||
Search,
|
||||
SwitchButton,
|
||||
Unlock,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import MetricTile from '../components/MetricTile.vue'
|
||||
import PageHero from '../components/PageHero.vue'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction'
|
||||
import {
|
||||
@@ -13,9 +22,10 @@ import {
|
||||
unbindBinding,
|
||||
updateBindingIp,
|
||||
} from '../api'
|
||||
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
|
||||
import { formatCompactNumber, formatDateTime } from '../utils/formatters'
|
||||
|
||||
const defaultPageSize = 20
|
||||
const staleWindowDays = 30
|
||||
const dialogVisible = ref(false)
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
@@ -33,33 +43,20 @@ const filters = reactive({
|
||||
page_size: defaultPageSize,
|
||||
})
|
||||
const { loading, run } = useAsyncAction()
|
||||
const pageSizeOptions = [20, 50, 100]
|
||||
|
||||
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
|
||||
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length)
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / filters.page_size)))
|
||||
const visibleProtectedRate = computed(() => {
|
||||
if (!rows.value.length) {
|
||||
return 0
|
||||
const attentionCount = computed(() => rows.value.filter((item) => item.status === 2 || isDormant(item)).length)
|
||||
const currentWindowLabel = computed(() => {
|
||||
if (!total.value) {
|
||||
return '0-0'
|
||||
}
|
||||
return activeCount.value / rows.value.length
|
||||
const start = (filters.page - 1) * filters.page_size + 1
|
||||
const end = Math.min(filters.page * filters.page_size, total.value)
|
||||
return `${start}-${end}`
|
||||
})
|
||||
const opsCards = [
|
||||
{
|
||||
eyebrow: 'Unbind',
|
||||
title: 'Reset first-use bind',
|
||||
note: 'Deletes the authoritative record and the Redis cache entry so the next request can bind again.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Edit CIDR',
|
||||
title: 'Handle endpoint changes',
|
||||
note: 'Update the bound IP or subnet when an internal user changes devices, locations, or network segments.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Ban',
|
||||
title: 'Freeze compromised tokens',
|
||||
note: 'Banned tokens are blocked immediately even if the client IP still matches the stored CIDR.',
|
||||
},
|
||||
]
|
||||
|
||||
function parsePositiveInteger(value, fallbackValue) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
@@ -128,6 +125,80 @@ function requestParams() {
|
||||
}
|
||||
}
|
||||
|
||||
function elapsedDays(value) {
|
||||
if (!value) {
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
return Math.floor((Date.now() - new Date(value).getTime()) / 86400000)
|
||||
}
|
||||
|
||||
function isDormant(row) {
|
||||
return elapsedDays(row.last_used_at) >= staleWindowDays
|
||||
}
|
||||
|
||||
function formatLastSeen(value) {
|
||||
if (!value) {
|
||||
return 'No activity'
|
||||
}
|
||||
|
||||
const elapsedHours = Math.floor((Date.now() - new Date(value).getTime()) / 3600000)
|
||||
if (elapsedHours < 1) {
|
||||
return 'Active within 1h'
|
||||
}
|
||||
if (elapsedHours < 24) {
|
||||
return `Active ${elapsedHours}h ago`
|
||||
}
|
||||
|
||||
const days = Math.floor(elapsedHours / 24)
|
||||
if (days < staleWindowDays) {
|
||||
return `Active ${days}d ago`
|
||||
}
|
||||
return `Dormant ${days}d`
|
||||
}
|
||||
|
||||
function statusTone(row) {
|
||||
if (row.status === 2) {
|
||||
return 'danger'
|
||||
}
|
||||
return isDormant(row) ? 'warning' : 'success'
|
||||
}
|
||||
|
||||
function statusText(row) {
|
||||
if (row.status === 2) {
|
||||
return 'Banned'
|
||||
}
|
||||
return isDormant(row) ? 'Dormant' : 'Healthy'
|
||||
}
|
||||
|
||||
function ipTypeLabel(boundIp) {
|
||||
if (!boundIp) {
|
||||
return 'Unknown'
|
||||
}
|
||||
if (!boundIp.includes('/')) {
|
||||
return 'Single IP'
|
||||
}
|
||||
return boundIp.endsWith('/32') || boundIp.endsWith('/128') ? 'Single IP' : 'CIDR'
|
||||
}
|
||||
|
||||
function rowClassName({ row }) {
|
||||
if (row.status === 2) {
|
||||
return 'binding-row--banned'
|
||||
}
|
||||
if (isDormant(row)) {
|
||||
return 'binding-row--dormant'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function copyValue(value, label) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(value))
|
||||
ElMessage.success(`${label} copied.`)
|
||||
} catch {
|
||||
ElMessage.error(`Failed to copy ${label.toLowerCase()}.`)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBindings() {
|
||||
await run(async () => {
|
||||
const data = await fetchBindings(requestParams())
|
||||
@@ -205,6 +276,12 @@ async function onPageChange(value) {
|
||||
await syncBindings()
|
||||
}
|
||||
|
||||
async function onPageSizeChange(value) {
|
||||
filters.page_size = value
|
||||
filters.page = 1
|
||||
await syncBindings()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
(query) => {
|
||||
@@ -219,191 +296,227 @@ watch(
|
||||
<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."
|
||||
title="Operate token-to-IP bindings from one dense, searchable workbench"
|
||||
description="Search quickly, verify the last seen address, then edit CIDRs or remove stale registrations without hunting through secondary cards."
|
||||
>
|
||||
<template #aside>
|
||||
<div class="hero-stat-pair">
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Visible active share</span>
|
||||
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
|
||||
<span class="eyebrow">Matching total</span>
|
||||
<strong>{{ formatCompactNumber(total) }}</strong>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<span class="eyebrow">Page volume</span>
|
||||
<strong>{{ formatCompactNumber(rows.length) }}</strong>
|
||||
<span class="eyebrow">Needs review</span>
|
||||
<strong>{{ formatCompactNumber(attentionCount) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<el-button :icon="RefreshRight" plain @click="refreshBindings">Refresh</el-button>
|
||||
</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="binding-workbench panel">
|
||||
<div class="binding-head">
|
||||
<div class="binding-head-copy">
|
||||
<p class="eyebrow">Binding registry</p>
|
||||
<h3 class="section-title">Search, compare, and intervene fast</h3>
|
||||
<p class="muted">
|
||||
Focus stays on the table: search by token suffix or IP, inspect last activity, then change CIDR, unbind, or ban in place.
|
||||
</p>
|
||||
</div>
|
||||
<div class="binding-summary-strip" aria-label="Binding summary">
|
||||
<article class="binding-summary-card">
|
||||
<span class="binding-summary-label">Visible window</span>
|
||||
<strong>{{ currentWindowLabel }}</strong>
|
||||
<span class="muted">of {{ formatCompactNumber(total) }}</span>
|
||||
</article>
|
||||
<article class="binding-summary-card">
|
||||
<span class="binding-summary-label">Active on page</span>
|
||||
<strong>{{ formatCompactNumber(activeCount) }}</strong>
|
||||
<span class="muted">healthy rows</span>
|
||||
</article>
|
||||
<article class="binding-summary-card binding-summary-card--warn">
|
||||
<span class="binding-summary-label">Attention</span>
|
||||
<strong>{{ formatCompactNumber(attentionCount) }}</strong>
|
||||
<span class="muted">banned or dormant</span>
|
||||
</article>
|
||||
<article class="binding-summary-card binding-summary-card--danger">
|
||||
<span class="binding-summary-label">Banned</span>
|
||||
<strong>{{ formatCompactNumber(bannedCount) }}</strong>
|
||||
<span class="muted">blocked rows</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="content-grid content-grid--balanced">
|
||||
<article class="table-card panel">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="filter-field field-sm">
|
||||
<label class="filter-label" for="binding-token-suffix">Token Suffix</label>
|
||||
<el-input
|
||||
id="binding-token-suffix"
|
||||
v-model="filters.token_suffix"
|
||||
aria-label="Filter by token suffix"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="binding_token_suffix"
|
||||
placeholder="Token suffix..."
|
||||
@keyup.enter="searchBindings"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-field field-md">
|
||||
<label class="filter-label" for="binding-ip-filter">Bound CIDR</label>
|
||||
<el-input
|
||||
id="binding-ip-filter"
|
||||
v-model="filters.ip"
|
||||
aria-label="Filter by bound CIDR"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="binding_ip_filter"
|
||||
placeholder="192.168.1.0/24..."
|
||||
@keyup.enter="searchBindings"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-field field-status">
|
||||
<label class="filter-label" for="binding-status-filter">Status</label>
|
||||
<el-select
|
||||
id="binding-status-filter"
|
||||
v-model="filters.status"
|
||||
aria-label="Filter by binding status"
|
||||
clearable
|
||||
placeholder="Status..."
|
||||
>
|
||||
<el-option label="Active" :value="1" />
|
||||
<el-option label="Banned" :value="2" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<el-button @click="resetFilters">Reset Filters</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="searchBindings">Search Bindings</el-button>
|
||||
</div>
|
||||
<div class="binding-filter-grid">
|
||||
<div class="filter-field field-sm">
|
||||
<label class="filter-label" for="binding-token-suffix">Token suffix</label>
|
||||
<el-input
|
||||
id="binding-token-suffix"
|
||||
v-model="filters.token_suffix"
|
||||
aria-label="Filter by token suffix"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="binding_token_suffix"
|
||||
placeholder="sk...tail"
|
||||
@keyup.enter="searchBindings"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="data-table table-block">
|
||||
<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 @click="openEdit(row)">Edit CIDR</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
|
||||
>
|
||||
Remove Binding
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 1"
|
||||
type="warning"
|
||||
plain
|
||||
@click="confirmAction('Ban this token?', () => banBinding(row.id))"
|
||||
>
|
||||
Ban Token
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="success"
|
||||
plain
|
||||
@click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))"
|
||||
>
|
||||
Restore Token
|
||||
</el-button>
|
||||
<div class="filter-field field-md">
|
||||
<label class="filter-label" for="binding-ip-filter">Bound IP or CIDR</label>
|
||||
<el-input
|
||||
id="binding-ip-filter"
|
||||
v-model="filters.ip"
|
||||
aria-label="Filter by bound CIDR"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
name="binding_ip_filter"
|
||||
placeholder="192.168.1.0/24"
|
||||
@keyup.enter="searchBindings"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Connection /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="filter-field field-status">
|
||||
<label class="filter-label" for="binding-status-filter">Status</label>
|
||||
<el-select
|
||||
id="binding-status-filter"
|
||||
v-model="filters.status"
|
||||
aria-label="Filter by binding status"
|
||||
clearable
|
||||
placeholder="Any status"
|
||||
>
|
||||
<el-option label="Active" :value="1" />
|
||||
<el-option label="Banned" :value="2" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="filter-field field-page-size">
|
||||
<label class="filter-label" for="binding-page-size">Page size</label>
|
||||
<el-select
|
||||
id="binding-page-size"
|
||||
v-model="filters.page_size"
|
||||
aria-label="Bindings page size"
|
||||
@change="onPageSizeChange"
|
||||
>
|
||||
<el-option v-for="size in pageSizeOptions" :key="size" :label="`${size} rows`" :value="size" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="binding-actions">
|
||||
<el-button @click="resetFilters">Reset</el-button>
|
||||
<el-button :icon="RefreshRight" plain :loading="loading" @click="refreshBindings">Reload</el-button>
|
||||
<el-button type="primary" :icon="Search" :loading="loading" @click="searchBindings">Apply filters</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="binding-table-toolbar">
|
||||
<div class="inline-meta">
|
||||
<span class="status-chip">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
{{ formatCompactNumber(total) }} matched bindings
|
||||
</span>
|
||||
<span class="binding-table-note">Dormant means no activity for {{ staleWindowDays }} days or more.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-table binding-table">
|
||||
<el-table :data="rows" :row-class-name="rowClassName" v-loading="loading">
|
||||
<el-table-column label="Binding" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="binding-token-cell">
|
||||
<div class="binding-token-main">
|
||||
<strong>{{ row.token_display }}</strong>
|
||||
<span class="binding-id">#{{ row.id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<span class="muted">First seen {{ formatDateTime(row.first_used_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<div class="toolbar pagination-toolbar">
|
||||
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:current-page="filters.page"
|
||||
:page-size="filters.page_size"
|
||||
:total="total"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
<el-table-column label="Bound address" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="binding-ip-cell">
|
||||
<div class="binding-ip-line">
|
||||
<code>{{ row.bound_ip }}</code>
|
||||
<el-button text :icon="CopyDocument" @click="copyValue(row.bound_ip, 'Bound IP')">Copy</el-button>
|
||||
</div>
|
||||
<span class="muted">{{ ipTypeLabel(row.bound_ip) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<aside class="soft-grid">
|
||||
<article class="support-card">
|
||||
<p class="eyebrow">Operator guide</p>
|
||||
<h4>Choose the least disruptive action first</h4>
|
||||
<p>Prefer CIDR edits for normal workstation changes. Use unbind when you want the next successful request to re-register automatically.</p>
|
||||
</article>
|
||||
<el-table-column label="Health" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="binding-health-cell">
|
||||
<el-tag :type="statusTone(row)" round effect="dark">
|
||||
{{ statusText(row) }}
|
||||
</el-tag>
|
||||
<span class="muted">{{ row.status_label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<article class="support-card">
|
||||
<p class="eyebrow">Quick reference</p>
|
||||
<ul class="support-list" role="list">
|
||||
<li v-for="item in opsCards" :key="item.title" class="support-list-item">
|
||||
<span class="eyebrow">{{ item.eyebrow }}</span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span class="muted">{{ item.note }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<el-table-column label="Last activity" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div class="binding-activity-cell">
|
||||
<strong>{{ formatLastSeen(row.last_used_at) }}</strong>
|
||||
<span class="muted">{{ formatDateTime(row.last_used_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<article class="support-card">
|
||||
<p class="eyebrow">Visible ratio</p>
|
||||
<div class="support-kpi">
|
||||
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
|
||||
<p>Active records across the current page view.</p>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
<el-table-column label="Actions" min-width="360" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="binding-action-row">
|
||||
<el-button :icon="EditPen" @click="openEdit(row)">Edit CIDR</el-button>
|
||||
<el-button
|
||||
:icon="row.status === 1 ? Lock : Unlock"
|
||||
:type="row.status === 1 ? 'warning' : 'success'"
|
||||
plain
|
||||
@click="
|
||||
confirmAction(
|
||||
row.status === 1 ? 'Ban this token?' : 'Restore this token to active state?',
|
||||
() => (row.status === 1 ? banBinding(row.id) : unbanBinding(row.id)),
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ row.status === 1 ? 'Ban' : 'Restore' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
:icon="SwitchButton"
|
||||
type="danger"
|
||||
plain
|
||||
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
|
||||
>
|
||||
Unbind
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="toolbar pagination-toolbar">
|
||||
<span class="muted">Page {{ filters.page }} of {{ pageCount }}</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, 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">
|
||||
|
||||
Reference in New Issue
Block a user