refactor(admin): 收敛后台接口封装与页面状态逻辑
- 简化绑定和日志接口的查询、序列化与前端数据请求路径 - 统一登录流程与前端 API 调用层,补充后台图标依赖 - 抽取通用异步状态处理,减少多个管理页面的重复逻辑
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,3 +8,7 @@ wheels/
|
|||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
|
# Node-generated files
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
25
frontend/src/composables/useAsyncAction.js
Normal file
25
frontend/src/composables/useAsyncAction.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user