Files
sentinel/frontend/src/views/Bindings.vue

267 lines
8.1 KiB
Vue
Raw Normal View History

<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>