refactor(admin): 收敛后台接口封装与页面状态逻辑

- 简化绑定和日志接口的查询、序列化与前端数据请求路径
- 统一登录流程与前端 API 调用层,补充后台图标依赖
- 抽取通用异步状态处理,减少多个管理页面的重复逻辑
This commit is contained in:
2026-03-04 00:18:47 +08:00
parent ab1bd90c65
commit 0a1eeb9ddf
12 changed files with 556 additions and 305 deletions

4
.gitignore vendored
View File

@@ -8,3 +8,7 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
# Node-generated files
node_modules/
npm-debug.log*

View File

@@ -36,6 +36,21 @@ def to_binding_item(binding: TokenBinding, binding_service: BindingService) -> B
) )
def apply_binding_filters(
statement,
token_suffix: str | None,
ip: str | None,
status_filter: int | None,
):
if token_suffix:
statement = statement.where(TokenBinding.token_display.ilike(f"%{token_suffix}%"))
if ip:
statement = statement.where(cast(TokenBinding.bound_ip, String).ilike(f"%{ip}%"))
if status_filter in {STATUS_ACTIVE, STATUS_BANNED}:
statement = statement.where(TokenBinding.status == status_filter)
return statement
async def get_binding_or_404(session: AsyncSession, binding_id: int) -> TokenBinding: async def get_binding_or_404(session: AsyncSession, binding_id: int) -> TokenBinding:
binding = await session.get(TokenBinding, binding_id) binding = await session.get(TokenBinding, binding_id)
if binding is None: if binding is None:
@@ -54,6 +69,21 @@ def log_admin_action(request: Request, settings: Settings, action: str, binding_
) )
async def commit_binding_cache(binding: TokenBinding, binding_service: BindingService) -> None:
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
async def update_binding_status(
session: AsyncSession,
binding: TokenBinding,
status_code: int,
binding_service: BindingService,
) -> None:
binding.status = status_code
await session.commit()
await commit_binding_cache(binding, binding_service)
@router.get("", response_model=BindingListResponse) @router.get("", response_model=BindingListResponse)
async def list_bindings( async def list_bindings(
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
@@ -64,13 +94,7 @@ async def list_bindings(
session: AsyncSession = Depends(get_db_session), session: AsyncSession = Depends(get_db_session),
binding_service: BindingService = Depends(get_binding_service), binding_service: BindingService = Depends(get_binding_service),
) -> BindingListResponse: ) -> BindingListResponse:
statement = select(TokenBinding) statement = apply_binding_filters(select(TokenBinding), token_suffix, ip, status_filter)
if token_suffix:
statement = statement.where(TokenBinding.token_display.ilike(f"%{token_suffix}%"))
if ip:
statement = statement.where(cast(TokenBinding.bound_ip, String).ilike(f"%{ip}%"))
if status_filter in {STATUS_ACTIVE, STATUS_BANNED}:
statement = statement.where(TokenBinding.status == status_filter)
total_result = await session.execute(select(func.count()).select_from(statement.subquery())) total_result = await session.execute(select(func.count()).select_from(statement.subquery()))
total = int(total_result.scalar_one()) total = int(total_result.scalar_one())
@@ -116,7 +140,7 @@ async def update_bound_ip(
binding = await get_binding_or_404(session, payload.id) binding = await get_binding_or_404(session, payload.id)
binding.bound_ip = payload.bound_ip binding.bound_ip = payload.bound_ip
await session.commit() await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status) await commit_binding_cache(binding, binding_service)
log_admin_action(request, settings, "update_ip", payload.id) log_admin_action(request, settings, "update_ip", payload.id)
return {"success": True} return {"success": True}
@@ -130,9 +154,7 @@ async def ban_token(
binding_service: BindingService = Depends(get_binding_service), binding_service: BindingService = Depends(get_binding_service),
): ):
binding = await get_binding_or_404(session, payload.id) binding = await get_binding_or_404(session, payload.id)
binding.status = STATUS_BANNED await update_binding_status(session, binding, STATUS_BANNED, binding_service)
await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
log_admin_action(request, settings, "ban", payload.id) log_admin_action(request, settings, "ban", payload.id)
return {"success": True} return {"success": True}
@@ -146,8 +168,6 @@ async def unban_token(
binding_service: BindingService = Depends(get_binding_service), binding_service: BindingService = Depends(get_binding_service),
): ):
binding = await get_binding_or_404(session, payload.id) binding = await get_binding_or_404(session, payload.id)
binding.status = STATUS_ACTIVE await update_binding_status(session, binding, STATUS_ACTIVE, binding_service)
await session.commit()
await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status)
log_admin_action(request, settings, "unban", payload.id) log_admin_action(request, settings, "unban", payload.id)
return {"success": True} return {"success": True}

View File

@@ -34,6 +34,33 @@ def apply_log_filters(
return statement return statement
def to_log_item(item: InterceptLog) -> InterceptLogItem:
return InterceptLogItem(
id=item.id,
token_display=item.token_display,
bound_ip=str(item.bound_ip),
attempt_ip=str(item.attempt_ip),
alerted=item.alerted,
intercepted_at=item.intercepted_at,
)
def write_log_csv(buffer: io.StringIO, logs: list[InterceptLog]) -> None:
writer = csv.writer(buffer)
writer.writerow(["id", "token_display", "bound_ip", "attempt_ip", "alerted", "intercepted_at"])
for item in logs:
writer.writerow(
[
item.id,
item.token_display,
str(item.bound_ip),
str(item.attempt_ip),
item.alerted,
item.intercepted_at.isoformat(),
]
)
@router.get("", response_model=LogListResponse) @router.get("", response_model=LogListResponse)
async def list_logs( async def list_logs(
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
@@ -54,17 +81,7 @@ async def list_logs(
).all() ).all()
return LogListResponse( return LogListResponse(
items=[ items=[to_log_item(item) for item in logs],
InterceptLogItem(
id=item.id,
token_display=item.token_display,
bound_ip=str(item.bound_ip),
attempt_ip=str(item.attempt_ip),
alerted=item.alerted,
intercepted_at=item.intercepted_at,
)
for item in logs
],
total=total, total=total,
page=page, page=page,
page_size=page_size, page_size=page_size,
@@ -85,19 +102,7 @@ async def export_logs(
logs = (await session.scalars(statement)).all() logs = (await session.scalars(statement)).all()
buffer = io.StringIO() buffer = io.StringIO()
writer = csv.writer(buffer) write_log_csv(buffer, logs)
writer.writerow(["id", "token_display", "bound_ip", "attempt_ip", "alerted", "intercepted_at"])
for item in logs:
writer.writerow(
[
item.id,
item.token_display,
str(item.bound_ip),
str(item.attempt_ip),
item.alerted,
item.intercepted_at.isoformat(),
]
)
filename = f"sentinel-logs-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}.csv" filename = f"sentinel-logs-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}.csv"
return StreamingResponse( return StreamingResponse(

View File

@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.8.3", "axios": "^1.8.3",
"echarts": "^5.6.0", "echarts": "^5.6.0",
"element-plus": "^2.9.6", "element-plus": "^2.9.6",

View File

@@ -1,6 +1,7 @@
import axios from 'axios' import axios from 'axios'
const TOKEN_KEY = 'sentinel_admin_token' const TOKEN_KEY = 'sentinel_admin_token'
const LOGIN_PATH_SUFFIX = '/login'
export const api = axios.create({ export const api = axios.create({
baseURL: '/', baseURL: '/',
@@ -19,15 +20,23 @@ api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
clearAuthToken() redirectToLogin()
if (!window.location.pathname.endsWith('/login')) {
window.location.assign(`${import.meta.env.BASE_URL}login`)
}
} }
return Promise.reject(error) return Promise.reject(error)
}, },
) )
function redirectToLogin() {
clearAuthToken()
if (!window.location.pathname.endsWith(LOGIN_PATH_SUFFIX)) {
window.location.assign(`${import.meta.env.BASE_URL}login`)
}
}
function withData(promise) {
return promise.then((response) => response.data)
}
export function getAuthToken() { export function getAuthToken() {
return localStorage.getItem(TOKEN_KEY) return localStorage.getItem(TOKEN_KEY)
} }
@@ -45,59 +54,50 @@ export function humanizeError(error, fallback = 'Request failed.') {
} }
export async function login(password) { export async function login(password) {
const { data } = await api.post('/admin/api/login', { password }) return withData(api.post('/admin/api/login', { password }))
return data
} }
export async function fetchDashboard() { export async function fetchDashboard() {
const { data } = await api.get('/admin/api/dashboard') return withData(api.get('/admin/api/dashboard'))
return data
} }
export async function fetchBindings(params) { export async function fetchBindings(params) {
const { data } = await api.get('/admin/api/bindings', { params }) return withData(api.get('/admin/api/bindings', { params }))
return data
} }
export async function unbindBinding(id) { export async function unbindBinding(id) {
const { data } = await api.post('/admin/api/bindings/unbind', { id }) return withData(api.post('/admin/api/bindings/unbind', { id }))
return data
} }
export async function updateBindingIp(payload) { export async function updateBindingIp(payload) {
const { data } = await api.put('/admin/api/bindings/ip', payload) return withData(api.put('/admin/api/bindings/ip', payload))
return data
} }
export async function banBinding(id) { export async function banBinding(id) {
const { data } = await api.post('/admin/api/bindings/ban', { id }) return withData(api.post('/admin/api/bindings/ban', { id }))
return data
} }
export async function unbanBinding(id) { export async function unbanBinding(id) {
const { data } = await api.post('/admin/api/bindings/unban', { id }) return withData(api.post('/admin/api/bindings/unban', { id }))
return data
} }
export async function fetchLogs(params) { export async function fetchLogs(params) {
const { data } = await api.get('/admin/api/logs', { params }) return withData(api.get('/admin/api/logs', { params }))
return data
} }
export async function exportLogs(params) { export async function exportLogs(params) {
const response = await api.get('/admin/api/logs/export', { return withData(
params, api.get('/admin/api/logs/export', {
responseType: 'blob', params,
}) responseType: 'blob',
return response.data }),
)
} }
export async function fetchSettings() { export async function fetchSettings() {
const { data } = await api.get('/admin/api/settings') return withData(api.get('/admin/api/settings'))
return data
} }
export async function updateSettings(payload) { export async function updateSettings(payload) {
const { data } = await api.put('/admin/api/settings', payload) return withData(api.put('/admin/api/settings', payload))
return data
} }

View File

@@ -0,0 +1,25 @@
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { humanizeError } from '../api'
export function useAsyncAction() {
const loading = ref(false)
async function run(task, fallbackMessage) {
loading.value = true
try {
return await task()
} catch (error) {
ElMessage.error(humanizeError(error, fallbackMessage))
throw error
} finally {
loading.value = false
}
}
return {
loading,
run,
}
}

View File

@@ -195,6 +195,10 @@ body::before {
gap: 24px; gap: 24px;
} }
.content-grid--balanced {
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
}
.chart-card, .chart-card,
.table-card, .table-card,
.form-card { .form-card {
@@ -235,6 +239,49 @@ body::before {
gap: 16px; gap: 16px;
} }
.support-card {
padding: 20px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(243, 251, 248, 0.72));
border: 1px solid rgba(255, 255, 255, 0.32);
}
.support-card h4 {
margin: 10px 0 8px;
font-size: 1.08rem;
}
.support-card p {
margin: 0;
color: var(--sentinel-ink-soft);
}
.support-list {
display: grid;
gap: 10px;
}
.support-list-item {
display: grid;
gap: 4px;
padding: 12px 0;
border-top: 1px solid rgba(9, 22, 30, 0.08);
}
.support-list-item:first-child {
padding-top: 0;
border-top: 0;
}
.support-kpi {
display: grid;
gap: 8px;
}
.support-kpi strong {
font-size: 1.55rem;
}
.insight-card { .insight-card {
padding: 18px 20px; padding: 18px 20px;
border-radius: 22px; border-radius: 22px;
@@ -282,6 +329,7 @@ body::before {
padding: 42px; padding: 42px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 28px;
justify-content: space-between; justify-content: space-between;
color: #f7fffe; color: #f7fffe;
background: background:
@@ -328,6 +376,10 @@ body::before {
font-size: 0.82rem; font-size: 0.82rem;
} }
.status-chip .el-icon {
flex: 0 0 auto;
}
.stack { .stack {
display: grid; display: grid;
gap: 24px; gap: 24px;

View File

@@ -4,20 +4,23 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import MetricTile from '../components/MetricTile.vue' import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue' import PageHero from '../components/PageHero.vue'
import { useAsyncAction } from '../composables/useAsyncAction'
import { import {
banBinding, banBinding,
fetchBindings, fetchBindings,
humanizeError,
unbanBinding, unbanBinding,
unbindBinding, unbindBinding,
updateBindingIp, updateBindingIp,
} from '../api' } from '../api'
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters' import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
const loading = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const rows = ref([]) const rows = ref([])
const total = ref(0) const total = ref(0)
const form = reactive({
id: null,
bound_ip: '',
})
const filters = reactive({ const filters = reactive({
token_suffix: '', token_suffix: '',
ip: '', ip: '',
@@ -25,10 +28,7 @@ const filters = reactive({
page: 1, page: 1,
page_size: 20, page_size: 20,
}) })
const form = reactive({ const { loading, run } = useAsyncAction()
id: null,
bound_ip: '',
})
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length) const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length) const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length)
@@ -38,6 +38,23 @@ const visibleProtectedRate = computed(() => {
} }
return activeCount.value / rows.value.length return activeCount.value / rows.value.length
}) })
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 requestParams() { function requestParams() {
return { return {
@@ -50,16 +67,11 @@ function requestParams() {
} }
async function loadBindings() { async function loadBindings() {
loading.value = true await run(async () => {
try {
const data = await fetchBindings(requestParams()) const data = await fetchBindings(requestParams())
rows.value = data.items rows.value = data.items
total.value = data.total total.value = data.total
} catch (error) { }, 'Failed to load bindings.')
ElMessage.error(humanizeError(error, 'Failed to load bindings.'))
} finally {
loading.value = false
}
} }
function resetFilters() { function resetFilters() {
@@ -82,13 +94,11 @@ async function submitEdit() {
return return
} }
try { try {
await updateBindingIp({ id: form.id, bound_ip: form.bound_ip }) await run(() => updateBindingIp({ id: form.id, bound_ip: form.bound_ip }), 'Failed to update binding.')
ElMessage.success('Binding updated.') ElMessage.success('Binding updated.')
dialogVisible.value = false dialogVisible.value = false
await loadBindings() await loadBindings()
} catch (error) { } catch {}
ElMessage.error(humanizeError(error, 'Failed to update binding.'))
}
} }
async function confirmAction(title, action) { async function confirmAction(title, action) {
@@ -98,13 +108,12 @@ async function confirmAction(title, action) {
cancelButtonText: 'Cancel', cancelButtonText: 'Cancel',
type: 'warning', type: 'warning',
}) })
await action() await run(action, 'Operation failed.')
await loadBindings() await loadBindings()
} catch (error) { } catch (error) {
if (error === 'cancel') { if (error === 'cancel') {
return return
} }
ElMessage.error(humanizeError(error, 'Operation failed.'))
} }
} }
@@ -166,88 +175,117 @@ onMounted(() => {
/> />
</section> </section>
<section class="table-card panel"> <section class="content-grid content-grid--balanced">
<div class="toolbar"> <article class="table-card panel">
<div class="toolbar-left"> <div class="toolbar">
<el-input v-model="filters.token_suffix" placeholder="Token suffix" clearable style="width: 180px;" /> <div class="toolbar-left">
<el-input v-model="filters.ip" placeholder="Bound IP or CIDR" clearable style="width: 220px;" /> <el-input v-model="filters.token_suffix" placeholder="Token suffix" clearable style="width: 180px;" />
<el-select v-model="filters.status" placeholder="Status" clearable style="width: 150px;"> <el-input v-model="filters.ip" placeholder="Bound IP or CIDR" clearable style="width: 220px;" />
<el-option label="Active" :value="1" /> <el-select v-model="filters.status" placeholder="Status" clearable style="width: 150px;">
<el-option label="Banned" :value="2" /> <el-option label="Active" :value="1" />
</el-select> <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>
<div class="toolbar-right"> <div class="data-table" style="margin-top: 20px;">
<el-button @click="resetFilters">Reset</el-button> <el-table :data="rows" v-loading="loading">
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadBindings()">Search</el-button> <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>
</div>
<div class="data-table" style="margin-top: 20px;"> <div class="toolbar" style="margin-top: 18px;">
<el-table :data="rows" v-loading="loading"> <span class="muted">Page {{ filters.page }} of {{ Math.max(1, Math.ceil(total / filters.page_size)) }}</span>
<el-table-column prop="id" label="ID" width="90" /> <el-pagination
<el-table-column prop="token_display" label="Token" min-width="170" /> background
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" /> layout="prev, pager, next"
<el-table-column label="Status" width="120"> :current-page="filters.page"
<template #default="{ row }"> :page-size="filters.page_size"
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round> :total="total"
{{ row.status_label }} @current-change="onPageChange"
</el-tag> />
</template> </div>
</el-table-column> </article>
<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;"> <aside class="soft-grid">
<span class="muted">Page {{ filters.page }} of {{ Math.max(1, Math.ceil(total / filters.page_size)) }}</span> <article class="support-card">
<el-pagination <p class="eyebrow">Operator guide</p>
background <h4>Choose the least disruptive action first</h4>
layout="prev, pager, next" <p>Prefer CIDR edits for normal workstation changes. Use unbind when you want the next successful request to re-register automatically.</p>
:current-page="filters.page" </article>
:page-size="filters.page_size"
:total="total" <article class="support-card">
@current-change="onPageChange" <p class="eyebrow">Quick reference</p>
/> <div class="support-list">
</div> <div 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>
</div>
</div>
</article>
<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>
</section> </section>
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px"> <el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">

View File

@@ -1,15 +1,14 @@
<script setup> <script setup>
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import MetricTile from '../components/MetricTile.vue' import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue' import PageHero from '../components/PageHero.vue'
import { fetchDashboard, humanizeError } from '../api' import { useAsyncAction } from '../composables/useAsyncAction'
import { fetchDashboard } from '../api'
import { usePolling } from '../composables/usePolling' import { usePolling } from '../composables/usePolling'
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters' import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
const loading = ref(false)
const dashboard = ref({ const dashboard = ref({
today: { total: 0, allowed: 0, intercepted: 0 }, today: { total: 0, allowed: 0, intercepted: 0 },
bindings: { active: 0, banned: 0 }, bindings: { active: 0, banned: 0 },
@@ -17,6 +16,7 @@ const dashboard = ref({
recent_intercepts: [], recent_intercepts: [],
}) })
const chartElement = ref(null) const chartElement = ref(null)
const { loading, run } = useAsyncAction()
let chart let chart
const interceptRate = computed(() => { const interceptRate = computed(() => {
@@ -105,15 +105,10 @@ async function renderChart() {
} }
async function loadDashboard() { async function loadDashboard() {
loading.value = true await run(async () => {
try {
dashboard.value = await fetchDashboard() dashboard.value = await fetchDashboard()
await renderChart() await renderChart()
} catch (error) { }, 'Failed to load dashboard.')
ElMessage.error(humanizeError(error, 'Failed to load dashboard.'))
} finally {
loading.value = false
}
} }
function resizeChart() { function resizeChart() {

View File

@@ -1,15 +1,33 @@
<script setup> <script setup>
import { reactive, ref } from 'vue' import { reactive } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { humanizeError, login, setAuthToken } from '../api' import { useAsyncAction } from '../composables/useAsyncAction'
import { login, setAuthToken } from '../api'
const router = useRouter() const router = useRouter()
const loading = ref(false)
const form = reactive({ const form = reactive({
password: '', password: '',
}) })
const { loading, run } = useAsyncAction()
const loginSignals = [
{
eyebrow: 'Proxy path',
title: 'Streaming request relay',
note: 'Headers and body pass through to the downstream API without buffering full model responses.',
},
{
eyebrow: 'Key policy',
title: 'First-use IP binding',
note: 'Every bearer token is pinned to a trusted client IP or CIDR on its first successful call.',
},
{
eyebrow: 'Operator safety',
title: 'JWT + lockout',
note: 'Admin login is rate-limited by source IP and issues an 8-hour signed token on success.',
},
]
async function submit() { async function submit() {
if (!form.password) { if (!form.password) {
@@ -17,24 +35,19 @@ async function submit() {
return return
} }
loading.value = true
try { try {
const data = await login(form.password) const data = await run(() => login(form.password), 'Login failed.')
setAuthToken(data.access_token) setAuthToken(data.access_token)
ElMessage.success('Authentication complete.') ElMessage.success('Authentication complete.')
await router.push({ name: 'dashboard' }) await router.push({ name: 'dashboard' })
} catch (error) { } catch {}
ElMessage.error(humanizeError(error, 'Login failed.'))
} finally {
loading.value = false
}
} }
</script> </script>
<template> <template>
<div class="login-shell"> <div class="login-shell">
<section class="login-stage panel"> <section class="login-stage panel">
<div> <div class="login-stage-copy">
<p class="eyebrow">Edge enforcement</p> <p class="eyebrow">Edge enforcement</p>
<h1>Key-IP Sentinel</h1> <h1>Key-IP Sentinel</h1>
<p class="login-copy"> <p class="login-copy">
@@ -43,14 +56,22 @@ async function submit() {
</p> </p>
</div> </div>
<div class="login-signal-grid">
<article v-for="item in loginSignals" :key="item.title" class="login-signal-card">
<p class="eyebrow">{{ item.eyebrow }}</p>
<h3>{{ item.title }}</h3>
<p>{{ item.note }}</p>
</article>
</div>
<div class="stack"> <div class="stack">
<div class="status-chip"> <div class="status-chip status-chip--strong">
<el-icon><Lock /></el-icon> <el-icon><Lock /></el-icon>
Zero-trust binding perimeter Zero-trust perimeter
</div> </div>
<div class="status-chip"> <div class="status-chip">
<el-icon><Connection /></el-icon> <el-icon><Connection /></el-icon>
Live downstream relay with SSE passthrough Live downstream relay
</div> </div>
</div> </div>
</section> </section>
@@ -76,6 +97,13 @@ async function submit() {
Enter control plane Enter control plane
</el-button> </el-button>
</el-form> </el-form>
<div class="login-divider" />
<div class="login-footer-note">
<span class="eyebrow">Security note</span>
<p>Failed admin attempts are rate-limited by client IP before a JWT is issued.</p>
</div>
</div> </div>
</section> </section>
</div> </div>
@@ -85,4 +113,46 @@ async function submit() {
.w-full { .w-full {
width: 100%; width: 100%;
} }
.login-stage-copy {
display: grid;
gap: 10px;
}
.login-signal-grid {
display: grid;
gap: 14px;
}
.login-signal-card {
padding: 18px 20px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
border: 1px solid rgba(255, 255, 255, 0.12);
}
.login-signal-card h3 {
margin: 10px 0 8px;
font-size: 1.08rem;
}
.login-signal-card p:last-child {
margin: 0;
color: rgba(247, 255, 254, 0.78);
}
.status-chip--strong {
background: rgba(255, 255, 255, 0.18);
}
.login-divider {
height: 1px;
margin: 24px 0 18px;
background: linear-gradient(90deg, rgba(9, 22, 30, 0.06), rgba(11, 158, 136, 0.28), rgba(9, 22, 30, 0.06));
}
.login-footer-note p {
margin: 8px 0 0;
color: var(--sentinel-ink-soft);
}
</style> </style>

View File

@@ -1,14 +1,12 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import MetricTile from '../components/MetricTile.vue' import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue' import PageHero from '../components/PageHero.vue'
import { exportLogs, fetchLogs, humanizeError } from '../api' import { useAsyncAction } from '../composables/useAsyncAction'
import { exportLogs, fetchLogs } from '../api'
import { downloadBlob, formatCompactNumber, formatDateTime } from '../utils/formatters' import { downloadBlob, formatCompactNumber, formatDateTime } from '../utils/formatters'
const loading = ref(false)
const exporting = ref(false)
const rows = ref([]) const rows = ref([])
const total = ref(0) const total = ref(0)
const filters = reactive({ const filters = reactive({
@@ -18,9 +16,24 @@ const filters = reactive({
page: 1, page: 1,
page_size: 20, page_size: 20,
}) })
const { loading, run } = useAsyncAction()
const { loading: exporting, run: runExport } = useAsyncAction()
const alertedCount = computed(() => rows.value.filter((item) => item.alerted).length) const alertedCount = computed(() => rows.value.filter((item) => item.alerted).length)
const uniqueAttempts = computed(() => new Set(rows.value.map((item) => item.attempt_ip)).size) const uniqueAttempts = computed(() => new Set(rows.value.map((item) => item.attempt_ip)).size)
const pendingCount = computed(() => rows.value.length - alertedCount.value)
const intelCards = [
{
eyebrow: 'Escalation',
title: 'Alerted rows',
note: 'Rows marked alerted already crossed the Redis threshold window and were included in a webhook escalation.',
},
{
eyebrow: 'Forensics',
title: 'Attempt IP review',
note: 'Correlate repeated attempt IPs with internal NAT ranges or unknown external addresses before acting on the token.',
},
]
function requestParams() { function requestParams() {
return { return {
@@ -34,33 +47,27 @@ function requestParams() {
} }
async function loadLogs() { async function loadLogs() {
loading.value = true await run(async () => {
try {
const data = await fetchLogs(requestParams()) const data = await fetchLogs(requestParams())
rows.value = data.items rows.value = data.items
total.value = data.total total.value = data.total
} catch (error) { }, 'Failed to load logs.')
ElMessage.error(humanizeError(error, 'Failed to load logs.'))
} finally {
loading.value = false
}
} }
async function handleExport() { async function handleExport() {
exporting.value = true
try { try {
const blob = await exportLogs({ const blob = await runExport(
token: filters.token || undefined, () =>
attempt_ip: filters.attempt_ip || undefined, exportLogs({
start_time: filters.time_range?.[0] || undefined, token: filters.token || undefined,
end_time: filters.time_range?.[1] || undefined, attempt_ip: filters.attempt_ip || undefined,
}) start_time: filters.time_range?.[0] || undefined,
end_time: filters.time_range?.[1] || undefined,
}),
'Failed to export logs.',
)
downloadBlob(blob, 'sentinel-logs.csv') downloadBlob(blob, 'sentinel-logs.csv')
} catch (error) { } catch {}
ElMessage.error(humanizeError(error, 'Failed to export logs.'))
} finally {
exporting.value = false
}
} }
function resetFilters() { function resetFilters() {
@@ -71,6 +78,11 @@ function resetFilters() {
loadLogs() loadLogs()
} }
function onPageChange(value) {
filters.page = value
loadLogs()
}
onMounted(() => { onMounted(() => {
loadLogs() loadLogs()
}) })
@@ -128,56 +140,79 @@ onMounted(() => {
/> />
</section> </section>
<section class="table-card panel"> <section class="content-grid content-grid--balanced">
<div class="toolbar"> <article class="table-card panel">
<div class="toolbar-left"> <div class="toolbar">
<el-input v-model="filters.token" placeholder="Masked token" clearable style="width: 180px;" /> <div class="toolbar-left">
<el-input v-model="filters.attempt_ip" placeholder="Attempt IP" clearable style="width: 180px;" /> <el-input v-model="filters.token" placeholder="Masked token" clearable style="width: 180px;" />
<el-date-picker <el-input v-model="filters.attempt_ip" placeholder="Attempt IP" clearable style="width: 180px;" />
v-model="filters.time_range" <el-date-picker
type="datetimerange" v-model="filters.time_range"
range-separator="to" type="datetimerange"
start-placeholder="Start time" range-separator="to"
end-placeholder="End time" start-placeholder="Start time"
value-format="YYYY-MM-DDTHH:mm:ssZ" end-placeholder="End time"
value-format="YYYY-MM-DDTHH:mm:ssZ"
/>
</div>
<div class="toolbar-right">
<el-button @click="resetFilters">Reset</el-button>
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadLogs()">Search</el-button>
</div>
</div>
<div class="data-table" style="margin-top: 20px;">
<el-table :data="rows" v-loading="loading">
<el-table-column prop="intercepted_at" label="Time" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.intercepted_at) }}</template>
</el-table-column>
<el-table-column prop="token_display" label="Token" min-width="170" />
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="170" />
<el-table-column prop="attempt_ip" label="Attempt IP" min-width="160" />
<el-table-column label="Alerted" width="120">
<template #default="{ row }">
<el-tag :type="row.alerted ? 'danger' : 'info'" round>
{{ row.alerted ? 'Yes' : 'No' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="toolbar" style="margin-top: 18px;">
<span class="muted">Total matching logs: {{ total }}</span>
<el-pagination
background
layout="prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="onPageChange"
/> />
</div> </div>
</article>
<div class="toolbar-right"> <aside class="soft-grid">
<el-button @click="resetFilters">Reset</el-button> <article class="support-card">
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadLogs()">Search</el-button> <p class="eyebrow">On this page</p>
</div> <div class="support-kpi">
</div> <strong>{{ formatCompactNumber(pendingCount) }}</strong>
<p>Visible rows still below the escalation threshold or not yet marked as alerted.</p>
</div>
</article>
<div class="data-table" style="margin-top: 20px;"> <article class="support-card">
<el-table :data="rows" v-loading="loading"> <p class="eyebrow">Incident review</p>
<el-table-column prop="intercepted_at" label="Time" min-width="190"> <div class="support-list">
<template #default="{ row }">{{ formatDateTime(row.intercepted_at) }}</template> <div v-for="item in intelCards" :key="item.title" class="support-list-item">
</el-table-column> <span class="eyebrow">{{ item.eyebrow }}</span>
<el-table-column prop="token_display" label="Token" min-width="170" /> <strong>{{ item.title }}</strong>
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="170" /> <span class="muted">{{ item.note }}</span>
<el-table-column prop="attempt_ip" label="Attempt IP" min-width="160" /> </div>
<el-table-column label="Alerted" width="120"> </div>
<template #default="{ row }"> </article>
<el-tag :type="row.alerted ? 'danger' : 'info'" round> </aside>
{{ row.alerted ? 'Yes' : 'No' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="toolbar" style="margin-top: 18px;">
<span class="muted">Total matching logs: {{ total }}</span>
<el-pagination
background
layout="prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="(value) => { filters.page = value; loadLogs() }"
/>
</div>
</section> </section>
</div> </div>
</template> </template>

View File

@@ -1,14 +1,13 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import MetricTile from '../components/MetricTile.vue' import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue' import PageHero from '../components/PageHero.vue'
import { fetchSettings, humanizeError, updateSettings } from '../api' import { useAsyncAction } from '../composables/useAsyncAction'
import { fetchSettings, updateSettings } from '../api'
import { formatCompactNumber } from '../utils/formatters' import { formatCompactNumber } from '../utils/formatters'
const loading = ref(false)
const saving = ref(false)
const form = reactive({ const form = reactive({
alert_webhook_url: '', alert_webhook_url: '',
alert_threshold_count: 5, alert_threshold_count: 5,
@@ -16,42 +15,52 @@ const form = reactive({
archive_days: 90, archive_days: 90,
failsafe_mode: 'closed', failsafe_mode: 'closed',
}) })
const { loading, run } = useAsyncAction()
const { loading: saving, run: runSave } = useAsyncAction()
const thresholdMinutes = computed(() => Math.round(form.alert_threshold_seconds / 60)) const thresholdMinutes = computed(() => Math.round(form.alert_threshold_seconds / 60))
const webhookState = computed(() => (form.alert_webhook_url ? 'Configured' : 'Disabled')) const webhookState = computed(() => (form.alert_webhook_url ? 'Configured' : 'Disabled'))
const modeCards = computed(() => [
{
eyebrow: 'Closed mode',
title: 'Protect the perimeter',
note: 'Reject traffic if Redis and PostgreSQL are both unavailable. Choose this when abuse prevention has priority over service continuity.',
active: form.failsafe_mode === 'closed',
},
{
eyebrow: 'Open mode',
title: 'Preserve business flow',
note: 'Allow traffic to continue when the full binding backend is down. Choose this only when continuity requirements outweigh policy enforcement.',
active: form.failsafe_mode === 'open',
},
])
async function loadSettings() { async function loadSettings() {
loading.value = true await run(async () => {
try {
const data = await fetchSettings() const data = await fetchSettings()
form.alert_webhook_url = data.alert_webhook_url || '' form.alert_webhook_url = data.alert_webhook_url || ''
form.alert_threshold_count = data.alert_threshold_count form.alert_threshold_count = data.alert_threshold_count
form.alert_threshold_seconds = data.alert_threshold_seconds form.alert_threshold_seconds = data.alert_threshold_seconds
form.archive_days = data.archive_days form.archive_days = data.archive_days
form.failsafe_mode = data.failsafe_mode form.failsafe_mode = data.failsafe_mode
} catch (error) { }, 'Failed to load runtime settings.')
ElMessage.error(humanizeError(error, 'Failed to load runtime settings.'))
} finally {
loading.value = false
}
} }
async function saveSettings() { async function saveSettings() {
saving.value = true
try { try {
await updateSettings({ await runSave(
alert_webhook_url: form.alert_webhook_url || null, () =>
alert_threshold_count: form.alert_threshold_count, updateSettings({
alert_threshold_seconds: form.alert_threshold_seconds, alert_webhook_url: form.alert_webhook_url || null,
archive_days: form.archive_days, alert_threshold_count: form.alert_threshold_count,
failsafe_mode: form.failsafe_mode, alert_threshold_seconds: form.alert_threshold_seconds,
}) archive_days: form.archive_days,
failsafe_mode: form.failsafe_mode,
}),
'Failed to update runtime settings.',
)
ElMessage.success('Runtime settings updated.') ElMessage.success('Runtime settings updated.')
} catch (error) { } catch {}
ElMessage.error(humanizeError(error, 'Failed to update runtime settings.'))
} finally {
saving.value = false
}
} }
onMounted(() => { onMounted(() => {
@@ -111,7 +120,7 @@ onMounted(() => {
/> />
</section> </section>
<section class="content-grid"> <section class="content-grid content-grid--balanced">
<article class="form-card panel"> <article class="form-card panel">
<p class="eyebrow">Alert window</p> <p class="eyebrow">Alert window</p>
<h3 class="section-title">Thresholds and webhook delivery</h3> <h3 class="section-title">Thresholds and webhook delivery</h3>
@@ -138,32 +147,29 @@ onMounted(() => {
</el-form> </el-form>
</article> </article>
<article class="form-card panel"> <aside class="soft-grid">
<p class="eyebrow">Retention</p> <article class="form-card panel">
<h3 class="section-title">Archive stale bindings</h3> <p class="eyebrow">Retention</p>
<h3 class="section-title">Archive stale bindings</h3>
<el-form label-position="top" v-loading="loading"> <el-form label-position="top" v-loading="loading">
<el-form-item label="Archive inactive bindings after N days"> <el-form-item label="Archive inactive bindings after N days">
<el-slider v-model="form.archive_days" :min="7" :max="365" :step="1" show-input /> <el-slider v-model="form.archive_days" :min="7" :max="365" :step="1" show-input />
</el-form-item> </el-form-item>
</el-form> </el-form>
</article>
<div class="stack"> <article
<div class="panel" style="padding: 16px; border-radius: 20px;"> v-for="item in modeCards"
<p class="eyebrow">Closed mode</p> :key="item.title"
<p class="muted" style="margin: 10px 0 0;"> class="support-card"
Reject traffic if both Redis and PostgreSQL are unavailable. Use this for security-first deployments. :style="item.active ? 'box-shadow: inset 0 0 0 1px rgba(11, 158, 136, 0.36);' : ''"
</p> >
</div> <p class="eyebrow">{{ item.eyebrow }}</p>
<h4>{{ item.title }}</h4>
<div class="panel" style="padding: 16px; border-radius: 20px;"> <p>{{ item.note }}</p>
<p class="eyebrow">Open mode</p> </article>
<p class="muted" style="margin: 10px 0 0;"> </aside>
Allow requests to keep business traffic flowing when the full binding backend is unavailable.
</p>
</div>
</div>
</article>
</section> </section>
</div> </div>
</template> </template>