Unify NetX UI to compact ops chrome and add API key purge.

Align network/list shells and audit header with industrial panels; allow permanently deleting revoked API keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-10-11 16:20:31 +08:00
parent 4d60601050
commit 046029aa16
11 changed files with 417 additions and 286 deletions

View file

@ -30,6 +30,7 @@ from .auth_service import (
list_users, list_users,
login_issue_token, login_issue_token,
refresh_login_tokens, refresh_login_tokens,
delete_api_token,
revoke_api_token, revoke_api_token,
revoke_auth_session_for_user, revoke_auth_session_for_user,
revoke_auth_sessions, revoke_auth_sessions,
@ -537,3 +538,27 @@ def api_revoke_token(
detail={"token_id": row.id, "name": row.name}, detail={"token_id": row.id, "name": row.name},
) )
return {"ok": True, "id": row.id} return {"ok": True, "id": row.id}
@router.delete("/v1/api-tokens/{token_id}/purge")
def api_purge_token(
token_id: str,
request: Request,
ctx: Annotated[AuthContext, Depends(require_user)],
db: Session = Depends(get_db),
) -> dict[str, Any]:
row = delete_api_token(db, token_id=token_id, actor=ctx.user)
ip, ua = _client_meta(request)
write_audit(
db,
action="api_tokens.purge",
actor_user_id=ctx.user.id,
actor_username=ctx.user.username,
method="DELETE",
path=f"/v1/api-tokens/{token_id}/purge",
status_code=200,
client_ip=ip,
user_agent=ua,
detail={"token_id": row.id, "name": row.name},
)
return {"ok": True, "id": row.id}

View file

@ -667,6 +667,18 @@ def revoke_api_token(db: Session, *, token_id: str, actor: AppUser) -> ApiToken:
return row return row
def delete_api_token(db: Session, *, token_id: str, actor: AppUser) -> ApiToken:
"""Permanently remove an API key row (revoked or active)."""
row = db.query(ApiToken).filter(ApiToken.id == str(token_id)).one_or_none()
if row is None:
raise HTTPException(status_code=404, detail="api_token_not_found")
if actor.role != "admin" and row.user_id != actor.id:
raise HTTPException(status_code=403, detail="forbidden")
db.delete(row)
db.commit()
return row
def update_api_token( def update_api_token(
db: Session, db: Session,
*, *,

View file

@ -440,6 +440,32 @@ class AuthApiTests(unittest.TestCase):
) )
self.assertEqual(empty.status_code, 400) self.assertEqual(empty.status_code, 400)
def test_api_token_purge_removes_row(self) -> None:
token = self._login()
created = self.client.post(
"/v1/api-tokens",
headers={"Authorization": f"Bearer {token}"},
json={"name": "to-purge", "expires_in_days": 7, "scopes": ["ne:read"]},
)
self.assertEqual(created.status_code, 200, created.text)
tid = created.json()["token"]["id"]
revoked = self.client.delete(
f"/v1/api-tokens/{tid}",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(revoked.status_code, 200, revoked.text)
listed = self.client.get("/v1/api-tokens", headers={"Authorization": f"Bearer {token}"})
self.assertEqual(listed.status_code, 200, listed.text)
self.assertTrue(any(r["id"] == tid and r.get("revoked") for r in listed.json().get("items") or []))
purged = self.client.delete(
f"/v1/api-tokens/{tid}/purge",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(purged.status_code, 200, purged.text)
listed2 = self.client.get("/v1/api-tokens", headers={"Authorization": f"Bearer {token}"})
self.assertEqual(listed2.status_code, 200, listed2.text)
self.assertFalse(any(r["id"] == tid for r in listed2.json().get("items") or []))
def test_api_token_max_count(self) -> None: def test_api_token_max_count(self) -> None:
token = self._login() token = self._login()
with patch("netx_api.auth_service.settings.auth_api_token_max_count", 2): with patch("netx_api.auth_service.settings.auth_api_token_max_count", 2):

View file

@ -145,8 +145,8 @@ export function moduleIdFromPath(pathname: string): string | null {
} }
export function getPageTitleKey(pathname: string): string { export function getPageTitleKey(pathname: string): string {
if (pathname.startsWith("/audit/tasks")) return "audit.tasks.title"; // Module windows show the module name in the header (same as Network Management),
if (pathname.startsWith("/audit/logs") || pathname === "/audit") return "audit.logsTitle"; // not the leaf page title — leaf titles live in the page toolbar / left nav.
const moduleId = moduleIdFromPath(pathname); const moduleId = moduleIdFromPath(pathname);
if (moduleId) { if (moduleId) {
const mod = getModuleById(moduleId); const mod = getModuleById(moduleId);

View file

@ -1611,9 +1611,9 @@ const en = {
colIp: "IP", colIp: "IP",
apiKeysTitle: "API Key management", apiKeysTitle: "API Key management",
apiKeysHelp: apiKeysHelp:
"The web UI uses login sessions, not API keys — keys are for MCP/scripts and netxops. Defaults on create: alarms:read + ne:read + ne:write + ne:exec + biz-monitor:read; refine later with Edit scopes (takes effect immediately, no need to regenerate). Secret is shown once. Admins may issue for other users; scopes cannot exceed the owner's. Unused scopes are reserved. System-wide cap: {{max}} non-revoked keys (revoke to free a slot).", "The web UI uses login sessions, not API keys — keys are for MCP/scripts and netxops. Defaults on create: alarms:read + ne:read + ne:write + ne:exec + biz-monitor:read; refine later with Edit scopes (takes effect immediately, no need to regenerate). Secret is shown once. Admins may issue for other users; scopes cannot exceed the owner's. Unused scopes are reserved. System-wide cap: {{max}} non-revoked keys (revoke or delete to free a slot).",
tokenQuota: "Active {{active}} / {{max}}", tokenQuota: "Active {{active}} / {{max}}",
tokenLimitReached: "API key limit reached ({{max}}). Revoke unused keys first.", tokenLimitReached: "API key limit reached ({{max}}). Revoke or delete unused keys first.",
tokenName: "Name", tokenName: "Name",
expiresIn: "Expiry", expiresIn: "Expiry",
expire7d: "7 days", expire7d: "7 days",
@ -1627,6 +1627,7 @@ const en = {
tokenCreated: "API key created", tokenCreated: "API key created",
tokenUpdated: "Scopes updated", tokenUpdated: "Scopes updated",
tokenRevoked: "Revoked", tokenRevoked: "Revoked",
tokenDeleted: "Deleted",
tokenOnceHint: "Copy and store this secret now; it will not be shown again:", tokenOnceHint: "Copy and store this secret now; it will not be shown again:",
copyToken: "Copy", copyToken: "Copy",
tokenCopied: "Copied", tokenCopied: "Copied",
@ -1637,7 +1638,9 @@ const en = {
tokenStatusExpired: "Expired", tokenStatusExpired: "Expired",
tokenStatusRevoked: "Revoked", tokenStatusRevoked: "Revoked",
revokeToken: "Revoke", revokeToken: "Revoke",
revokeConfirm: "Revoke this API key?", revokeConfirm: "Revoke this API key? It stops working immediately; the record remains.",
deleteToken: "Delete",
deleteConfirm: "Permanently delete this API key? This cannot be undone.",
editScopes: "Edit scopes", editScopes: "Edit scopes",
saveScopes: "Save scopes", saveScopes: "Save scopes",
scopesUnsetLegacy: "Unrestricted (same as owner)", scopesUnsetLegacy: "Unrestricted (same as owner)",

View file

@ -1599,9 +1599,9 @@ const zh = {
colIp: "IP", colIp: "IP",
apiKeysTitle: "API Key 管理", apiKeysTitle: "API Key 管理",
apiKeysHelp: apiKeysHelp:
"网页登录不用 API Key,Key 给 MCP/脚本与 netxops 用。生成时默认 alarms:read + ne:read + ne:write + ne:exec + biz-monitor:read,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接的项为预留。全站未吊销 Key 上限 {{max}} 把(吊销后可再建)。", "网页登录不用 API Key,Key 给 MCP/脚本与 netxops 用。生成时默认 alarms:read + ne:read + ne:write + ne:exec + biz-monitor:read,之后用「改权限」增减(保存即生效,不必重生明文)。明文仅创建时显示一次。管理员可代其他用户签发,权限不能超过所属用户已有能力;未接的项为预留。全站未吊销 Key 上限 {{max}} 把(吊销或删除后可再建)。",
tokenQuota: "未吊销 {{active}} / {{max}}", tokenQuota: "未吊销 {{active}} / {{max}}",
tokenLimitReached: "已达 API Key 上限({{max}}),请先吊销不用的 Key", tokenLimitReached: "已达 API Key 上限({{max}}),请先吊销或删除不用的 Key",
tokenName: "名称", tokenName: "名称",
expiresIn: "有效期", expiresIn: "有效期",
expire7d: "7 天", expire7d: "7 天",
@ -1615,6 +1615,7 @@ const zh = {
tokenCreated: "API Key 已生成", tokenCreated: "API Key 已生成",
tokenUpdated: "权限已更新", tokenUpdated: "权限已更新",
tokenRevoked: "已吊销", tokenRevoked: "已吊销",
tokenDeleted: "已删除",
tokenOnceHint: "请立即复制保存,关闭后无法再次查看明文:", tokenOnceHint: "请立即复制保存,关闭后无法再次查看明文:",
copyToken: "复制", copyToken: "复制",
tokenCopied: "已复制到剪贴板", tokenCopied: "已复制到剪贴板",
@ -1625,7 +1626,9 @@ const zh = {
tokenStatusExpired: "已过期", tokenStatusExpired: "已过期",
tokenStatusRevoked: "已吊销", tokenStatusRevoked: "已吊销",
revokeToken: "吊销", revokeToken: "吊销",
revokeConfirm: "确定吊销该 API Key?", revokeConfirm: "确定吊销该 API Key?吊销后立即失效,记录仍保留。",
deleteToken: "删除",
deleteConfirm: "确定永久删除该 API Key?删除后无法恢复。",
editScopes: "改权限", editScopes: "改权限",
saveScopes: "保存权限", saveScopes: "保存权限",
scopesUnsetLegacy: "未限定(等同所属用户)", scopesUnsetLegacy: "未限定(等同所属用户)",

View file

@ -69,19 +69,24 @@ body {
--nm-chrome: #080d18; --nm-chrome: #080d18;
--nm-main: #0a101c; --nm-main: #0a101c;
--nm-nav: #101826; --nm-nav: #101826;
--nm-nav-border: rgba(148, 163, 184, 0.14); --nm-nav-border: #2b3748;
--nm-nav-hover: rgba(59, 130, 246, 0.1); --nm-nav-hover: rgba(107, 169, 236, 0.08);
--nm-nav-active: rgba(59, 130, 246, 0.16); --nm-nav-active: rgba(107, 169, 236, 0.12);
--nm-nav-ink: #e2e8f0; --nm-nav-ink: #e2e8f0;
--nm-nav-muted: #94a3b8; --nm-nav-muted: #9aa9bc;
--nm-panel: rgba(15, 23, 42, 0.78); --nm-panel: #0c1521;
--nm-panel-solid: #111a2b; --nm-panel-solid: #0c1521;
--nm-panel-border: rgba(148, 163, 184, 0.16); --nm-panel-border: #2b3748;
--nm-brand: #f1f5f9; --nm-brand: #f1f5f9;
--nm-text: #e2e8f0; --nm-text: #e2e8f0;
--nm-muted: #94a3b8; --nm-muted: #9aa9bc;
--nm-glass: rgba(15, 23, 42, 0.55); --nm-glass: rgba(12, 21, 33, 0.9);
--nm-glass-border: rgba(148, 163, 184, 0.18); --nm-glass-border: #2b3748;
/* Compact industrial ops (shared by network + list modules) */
--nm-ops-line: #2b3748;
--nm-ops-muted: #9aa9bc;
--nm-ops-accent: #6ba9ec;
--nm-ops-surface: #111c2a;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
@ -197,10 +202,10 @@ body {
.workbench--console { .workbench--console {
position: relative; position: relative;
--wb-corner: rgba(56, 189, 248, 0.55); --wb-corner: rgba(107, 169, 236, 0.45);
--wb-ink: #e2e8f0; --wb-ink: #e2e8f0;
--wb-muted: #94a3b8; --wb-muted: var(--nm-ops-muted, #9aa9bc);
--wb-cyan: #7dd3fc; --wb-cyan: var(--nm-ops-accent, #6ba9ec);
} }
.app-main:has(> .workbench) { .app-main:has(> .workbench) {
@ -230,18 +235,18 @@ body {
.wb-panel { .wb-panel {
position: relative; position: relative;
border: 1px solid rgba(56, 189, 248, 0.14); border: 1px solid var(--nm-ops-line, #2b3748);
border-radius: 4px; border-radius: 6px;
background: background:
linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 0 0 / 11px 2px no-repeat, linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 0 0 / 11px 2px no-repeat,
linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 0 0 / 2px 11px no-repeat, linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 0 0 / 2px 11px no-repeat,
linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 100% 0 / 11px 2px no-repeat, linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 100% 0 / 11px 2px no-repeat,
linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 100% 0 / 2px 11px no-repeat, linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 100% 0 / 2px 11px no-repeat,
linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 0 100% / 11px 2px no-repeat, linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 0 100% / 11px 2px no-repeat,
linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 0 100% / 2px 11px no-repeat, linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 0 100% / 2px 11px no-repeat,
linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 100% 100% / 11px 2px no-repeat, linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 100% 100% / 11px 2px no-repeat,
linear-gradient(var(--wb-corner, rgba(56, 189, 248, 0.55)) 0 0) 100% 100% / 2px 11px no-repeat, linear-gradient(var(--wb-corner, rgba(107, 169, 236, 0.45)) 0 0) 100% 100% / 2px 11px no-repeat,
linear-gradient(165deg, rgba(15, 23, 42, 0.78), rgba(2, 8, 23, 0.58)); #0c1521;
} }
.wb-panel::before, .wb-panel::before,
@ -1377,9 +1382,9 @@ button {
.app-main .card, .app-main .card,
.app-main .panel { .app-main .panel {
border: 1px solid var(--nm-panel-border, #1e293b); border: 1px solid var(--nm-ops-line, var(--nm-panel-border, #2b3748));
background: var(--nm-panel-solid, #0f172a); background: var(--nm-panel-solid, #0c1521);
border-radius: 10px; border-radius: 6px;
padding: 14px; padding: 14px;
box-shadow: none; box-shadow: none;
color: var(--nm-text, #e2e8f0); color: var(--nm-text, #e2e8f0);
@ -1387,9 +1392,9 @@ button {
.card, .card,
.panel { .panel {
border: 1px solid var(--nm-panel-border, #1e293b); border: 1px solid var(--nm-ops-line, var(--nm-panel-border, #2b3748));
background: var(--nm-panel-solid, #0f172a); background: var(--nm-panel-solid, #0c1521);
border-radius: 10px; border-radius: 6px;
padding: 14px; padding: 14px;
box-shadow: none; box-shadow: none;
color: var(--nm-text, #e2e8f0); color: var(--nm-text, #e2e8f0);
@ -2171,12 +2176,10 @@ pre {
.ume-page > .cards > .card, .ume-page > .cards > .card,
.system-page > .panel { .system-page > .panel {
padding: 18px 20px 20px; padding: 18px 20px 20px;
border: 1px solid var(--nm-panel-border); border: 1px solid var(--nm-ops-line);
border-radius: 14px; border-radius: 6px;
background: linear-gradient(180deg, rgba(22, 32, 50, 0.96), rgba(15, 23, 42, 0.92)); background: #0c1521;
box-shadow: box-shadow: none;
0 1px 0 rgba(255, 255, 255, 0.03) inset,
0 10px 28px rgba(0, 0, 0, 0.18);
} }
.ume-page > .cards { .ume-page > .cards {
@ -2192,7 +2195,20 @@ pre {
.system-page .panel__toolbar { .system-page .panel__toolbar {
margin-bottom: 14px; margin-bottom: 14px;
padding-bottom: 12px; padding-bottom: 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14); border-bottom: 1px solid var(--nm-ops-line);
}
.ne-page .button,
.ume-page .button,
.system-page .button,
.ne-page .input,
.ume-page .input,
.system-page .input,
.ne-page .ui-field__select,
.ume-page .ui-field__select,
.system-page .ui-field__select {
border-radius: 4px;
box-shadow: none;
} }
.ne-page .panel__toolbar h2, .ne-page .panel__toolbar h2,
@ -2220,13 +2236,13 @@ pre {
.ume-page .card > .panel__toolbar { .ume-page .card > .panel__toolbar {
margin-bottom: 14px; margin-bottom: 14px;
padding-bottom: 12px; padding-bottom: 12px;
border-bottom: 1px solid var(--nm-panel-border, #c8d2de); border-bottom: 1px solid var(--nm-ops-line);
} }
.ume-page .card__section-title { .ume-page .card__section-title {
margin: 16px 0 10px; margin: 16px 0 10px;
padding-top: 12px; padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.14); border-top: 1px solid var(--nm-ops-line);
font-size: 14px; font-size: 14px;
font-weight: 650; font-weight: 650;
color: var(--nm-brand); color: var(--nm-brand);
@ -2259,13 +2275,13 @@ pre {
.ume-conn-col + .ume-conn-col { .ume-conn-col + .ume-conn-col {
padding-left: 28px; padding-left: 28px;
border-left: 1px solid rgba(148, 163, 184, 0.14); border-left: 1px solid var(--nm-ops-line);
} }
.ume-conn-panel .ume-ws-log { .ume-conn-panel .ume-ws-log {
margin-top: 16px; margin-top: 16px;
padding-top: 14px; padding-top: 14px;
border-top: 1px solid rgba(148, 163, 184, 0.14); border-top: 1px solid var(--nm-ops-line);
} }
@media (max-width: 960px) { @media (max-width: 960px) {
@ -2278,24 +2294,39 @@ pre {
padding-left: 0; padding-left: 0;
padding-top: 14px; padding-top: 14px;
border-left: 0; border-left: 0;
border-top: 1px solid rgba(148, 163, 184, 0.14); border-top: 1px solid var(--nm-ops-line);
} }
} }
.ume-page .pt-list-table-wrap { .ne-page .pt-list-table-wrap,
border: 1px solid rgba(148, 163, 184, 0.14); .ume-page .pt-list-table-wrap,
border-radius: 12px; .system-page .pt-list-table-wrap {
border: 1px solid var(--nm-ops-line);
border-radius: 4px;
overflow: hidden; overflow: hidden;
background: rgba(8, 13, 24, 0.28); background: rgba(8, 13, 24, 0.28);
} }
.ume-page .pt-list-table thead th { .ne-page .pt-list-table thead th,
font-size: 12px; .ume-page .pt-list-table thead th,
.system-page .pt-list-table thead th,
.ne-page .data-table thead th,
.ume-page .data-table thead th,
.system-page .data-table thead th {
background: #162232;
font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-transform: uppercase; text-transform: uppercase;
} }
.ne-page .pt-list-table tbody td,
.ume-page .pt-list-table tbody td,
.system-page .pt-list-table tbody td {
font-size: 12px;
border-bottom-color: var(--nm-ops-line);
}
.ume-page .ume-ws-log { .ume-page .ume-ws-log {
margin-top: 14px; margin-top: 14px;
display: flex; display: flex;
@ -2323,7 +2354,8 @@ pre {
.ume-page .ws-log-panel { .ume-page .ws-log-panel {
margin-top: 0; margin-top: 0;
max-height: 180px; max-height: 180px;
border-radius: 10px; border: 1px solid var(--nm-ops-line);
border-radius: 4px;
background: rgba(8, 13, 24, 0.55); background: rgba(8, 13, 24, 0.55);
} }
@ -2340,9 +2372,9 @@ pre {
align-items: center; align-items: center;
margin-bottom: 0; margin-bottom: 0;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid var(--nm-ops-line);
border-radius: 8px; border-radius: 4px;
background: rgba(15, 23, 42, 0.55); background: rgba(8, 13, 24, 0.45);
grid-template-columns: none; grid-template-columns: none;
} }
@ -2358,8 +2390,8 @@ pre {
max-width: 280px; max-width: 280px;
height: 32px; height: 32px;
margin: 0; margin: 0;
border: 1px solid rgba(148, 163, 184, 0.28); border: 1px solid var(--nm-ops-line);
border-radius: 6px; border-radius: 4px;
background: #0b1220; background: #0b1220;
color: #e2e8f0; color: #e2e8f0;
padding: 0 10px; padding: 0 10px;
@ -2385,8 +2417,8 @@ pre {
max-height: 32px; max-height: 32px;
flex: none; flex: none;
margin: 0; margin: 0;
border: 1px solid rgba(148, 163, 184, 0.28); border: 1px solid var(--nm-ops-line);
border-radius: 6px; border-radius: 4px;
background: #0b1220; background: #0b1220;
color: #e2e8f0; color: #e2e8f0;
padding: 0 10px; padding: 0 10px;
@ -3448,14 +3480,14 @@ pre {
.webcrt-sidebar { .webcrt-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--nm-nav); background: #0a121c;
border-right: 1px solid var(--nm-nav-border); border-right: 1px solid var(--nm-ops-line, var(--nm-nav-border));
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
color: var(--nm-text); color: var(--nm-text);
box-shadow: inset -1px 0 0 rgba(15, 27, 45, 0.04); box-shadow: none;
} }
.webcrt-sidebar__header { .webcrt-sidebar__header {
@ -5128,8 +5160,8 @@ pre {
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
min-height: 0; min-height: 0;
background: var(--nm-nav); background: #0a121c;
border: 1px solid var(--nm-nav-border); border: 1px solid var(--nm-ops-line, var(--nm-nav-border));
border-radius: 0; border-radius: 0;
border-left: 0; border-left: 0;
border-top: 0; border-top: 0;
@ -9342,24 +9374,22 @@ html.login-page--paused .login-page__flare {
} }
.network-nav { .network-nav {
width: 248px; width: 232px;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: background: #0a121c;
linear-gradient(180deg, rgba(59, 130, 246, 0.06), transparent 120px), border-right: 1px solid var(--nm-ops-line);
var(--nm-nav);
border-right: 1px solid var(--nm-nav-border);
min-height: 0; min-height: 0;
color: var(--nm-text); color: var(--nm-text);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18); box-shadow: none;
} }
.network-nav__scroll { .network-nav__scroll {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
padding: 10px 10px 20px; padding: 8px 8px 16px;
} }
.network-nav__accordion.accordion { .network-nav__accordion.accordion {
@ -9371,12 +9401,13 @@ html.login-page--paused .login-page__flare {
} }
.network-nav__group { .network-nav__group {
margin: 0 0 6px; margin: 0 0 2px;
border-radius: 10px; border-radius: 0;
background: transparent;
} }
.network-nav__group.is-active-group { .network-nav__group.is-active-group {
background: rgba(59, 130, 246, 0.05); background: transparent;
} }
.app-main .network-nav__group-toggle, .app-main .network-nav__group-toggle,
@ -9386,14 +9417,14 @@ html.login-page--paused .login-page__flare {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
padding: 10px 12px; padding: 8px 10px;
border: 0; border: 0;
border-radius: 10px; border-radius: 4px;
background: transparent; background: transparent;
color: var(--nm-muted); color: var(--nm-ops-muted);
font-size: 12px; font-size: 11px;
font-weight: 650; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
@ -9402,22 +9433,29 @@ html.login-page--paused .login-page__flare {
.app-main .network-nav__group-toggle:hover, .app-main .network-nav__group-toggle:hover,
.network-nav__group-toggle:hover, .network-nav__group-toggle:hover,
.network-nav .accordion__trigger.network-nav__group-toggle:hover { .network-nav .accordion__trigger.network-nav__group-toggle:hover {
background: var(--nm-nav-hover); background: rgba(17, 28, 42, 0.8);
color: var(--nm-brand); color: #d7e6f8;
} }
.network-nav__group.is-active-group .network-nav__group-toggle, .network-nav__group.is-active-group .network-nav__group-toggle,
.network-nav__group.is-active-group .accordion__trigger.network-nav__group-toggle { .network-nav__group.is-active-group .accordion__trigger.network-nav__group-toggle {
color: #93c5fd; color: var(--nm-ops-accent);
background: transparent;
} }
.network-nav__chevron, .network-nav__chevron,
.network-nav .accordion__indicator.network-nav__chevron { .network-nav .accordion__indicator.network-nav__chevron {
display: inline-flex; display: inline-flex;
width: 14px; width: 12px;
height: 14px; height: 12px;
color: #64748b; color: #64748b;
flex-shrink: 0; flex-shrink: 0;
opacity: 0.85;
}
.network-nav__group.is-active-group .network-nav__chevron,
.network-nav__group.is-active-group .accordion__indicator.network-nav__chevron {
color: var(--nm-ops-accent);
} }
.network-nav__group-label { .network-nav__group-label {
@ -9427,17 +9465,19 @@ html.login-page--paused .login-page__flare {
.network-nav .accordion__body-inner.network-nav__panel-body, .network-nav .accordion__body-inner.network-nav__panel-body,
.network-nav .network-nav__panel-body { .network-nav .network-nav__panel-body {
padding: 0 4px 8px !important; padding: 0 0 6px !important;
color: inherit !important; color: inherit !important;
} }
.network-nav__list { .network-nav__list {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0 4px; padding: 0 0 0 6px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
border-left: 1px solid var(--nm-ops-line);
margin-left: 14px;
} }
.network-nav__list > li { .network-nav__list > li {
@ -9448,45 +9488,47 @@ html.login-page--paused .login-page__flare {
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
padding: 9px 12px; padding: 7px 10px;
border-radius: 8px; border-radius: 4px;
color: var(--nm-muted); color: var(--nm-ops-muted);
text-decoration: none; text-decoration: none;
font-size: 13px; font-size: 13px;
line-height: 1.35; line-height: 1.35;
border-left: 0; border: 0;
box-shadow: none;
transition: transition:
background 140ms ease, background 120ms ease,
color 140ms ease, color 120ms ease;
box-shadow 140ms ease;
} }
.network-nav__link:hover { .network-nav__link:hover {
background: var(--nm-nav-hover); background: var(--nm-ops-surface);
color: var(--nm-text); color: #e8f0fa;
} }
.network-nav__link.is-active { .network-nav__link.is-active {
background: var(--nm-nav-active); background: rgba(107, 169, 236, 0.1);
color: #dbeafe; color: #edf5ff;
font-weight: 600; font-weight: 600;
box-shadow: inset 3px 0 0 var(--nm-accent); box-shadow: inset 2px 0 0 var(--nm-ops-accent);
} }
.network-nav__brand { .network-nav__brand {
padding: 16px 18px 14px; padding: 14px 14px 12px;
font-size: 13px; font-size: 12px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.06em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: var(--nm-brand); color: #edf5ff;
border-bottom: 1px solid var(--nm-nav-border); border-bottom: 1px solid var(--nm-ops-line);
background: rgba(8, 13, 24, 0.35); background: #0c1521;
} }
.network-nav__list--flat { .network-nav__list--flat {
padding: 4px; padding: 0;
gap: 2px; margin-left: 0;
border-left: 0;
gap: 1px;
} }
.audit-shell .network-main { .audit-shell .network-main {
@ -9504,22 +9546,21 @@ html.login-page--paused .login-page__flare {
var(--nm-main, var(--nm-chrome)); var(--nm-main, var(--nm-chrome));
} }
/* Compact ops chrome (aligned with biz-monitor / biz-compare). */
.network-main > .panel, .network-main > .panel,
.network-main .page-stack > .panel, .network-main .page-stack > .panel,
.network-main > .nm-page-panel { .network-main > .nm-page-panel {
padding: 18px 20px 20px; padding: 18px 20px 20px;
border: 1px solid var(--nm-panel-border); border: 1px solid var(--nm-ops-line);
border-radius: 14px; border-radius: 6px;
background: linear-gradient(180deg, rgba(22, 32, 50, 0.96), rgba(15, 23, 42, 0.92)); background: #0c1521;
box-shadow: box-shadow: none;
0 1px 0 rgba(255, 255, 255, 0.03) inset,
0 10px 28px rgba(0, 0, 0, 0.18);
} }
.network-main .panel__toolbar { .network-main .panel__toolbar {
margin-bottom: 16px; margin-bottom: 16px;
padding-bottom: 14px; padding-bottom: 14px;
border-bottom: 1px solid rgba(148, 163, 184, 0.14); border-bottom: 1px solid var(--nm-ops-line);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -9534,6 +9575,13 @@ html.login-page--paused .login-page__flare {
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.network-main .button,
.network-main .input,
.network-main .ui-field__select {
border-radius: 4px;
box-shadow: none;
}
.network-main .filter-inline { .network-main .filter-inline {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -9541,8 +9589,8 @@ html.login-page--paused .login-page__flare {
align-items: center; align-items: center;
margin-bottom: 0; margin-bottom: 0;
padding: 12px 14px; padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.16); border: 1px solid var(--nm-ops-line);
border-radius: 12px; border-radius: 4px;
background: rgba(8, 13, 24, 0.45); background: rgba(8, 13, 24, 0.45);
grid-template-columns: none; grid-template-columns: none;
} }
@ -9555,8 +9603,8 @@ html.login-page--paused .login-page__flare {
max-width: 280px; max-width: 280px;
height: 34px; height: 34px;
margin: 0; margin: 0;
border: 1px solid rgba(148, 163, 184, 0.24); border: 1px solid var(--nm-ops-line);
border-radius: 8px; border-radius: 4px;
background: #0b1220; background: #0b1220;
color: #e2e8f0; color: #e2e8f0;
padding: 0 12px; padding: 0 12px;
@ -9579,8 +9627,8 @@ html.login-page--paused .login-page__flare {
max-height: 34px; max-height: 34px;
flex: none; flex: none;
margin: 0; margin: 0;
border: 1px solid rgba(148, 163, 184, 0.24); border: 1px solid var(--nm-ops-line);
border-radius: 8px; border-radius: 4px;
background: #0b1220; background: #0b1220;
color: #e2e8f0; color: #e2e8f0;
padding: 0 12px; padding: 0 12px;
@ -9596,9 +9644,37 @@ html.login-page--paused .login-page__flare {
gap: 14px; gap: 14px;
} }
.network-main .pt-list-kpis {
gap: 0;
border: 1px solid var(--nm-ops-line);
border-radius: 4px;
overflow: hidden;
background: var(--nm-ops-surface);
}
.network-main .pt-list-kpi {
border: 0;
border-right: 1px solid var(--nm-ops-line);
border-radius: 0;
background: transparent;
}
.network-main .pt-list-kpi:last-child {
border-right: 0;
}
.network-main .pt-list-kpi__value {
font-family: ui-monospace, Consolas, monospace;
}
.network-main .pt-list-kpi__label {
font-size: 11px;
color: var(--nm-ops-muted);
}
.network-main .pt-list-table-wrap { .network-main .pt-list-table-wrap {
border: 1px solid rgba(148, 163, 184, 0.14); border: 1px solid var(--nm-ops-line);
border-radius: 12px; border-radius: 4px;
overflow: hidden; overflow: hidden;
background: rgba(8, 13, 24, 0.28); background: rgba(8, 13, 24, 0.28);
} }
@ -9608,9 +9684,9 @@ html.login-page--paused .login-page__flare {
} }
.network-main .pt-list-table thead th { .network-main .pt-list-table thead th {
background: rgba(15, 23, 42, 0.92); background: #162232;
color: #94a3b8; color: #94a3b8;
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-transform: uppercase; text-transform: uppercase;
@ -9621,7 +9697,8 @@ html.login-page--paused .login-page__flare {
.network-main .pt-list-table tbody td { .network-main .pt-list-table tbody td {
padding-top: 11px; padding-top: 11px;
padding-bottom: 11px; padding-bottom: 11px;
border-bottom-color: rgba(148, 163, 184, 0.08); font-size: 12px;
border-bottom-color: var(--nm-ops-line);
} }
.network-main .pt-list-table tbody tr:last-child td { .network-main .pt-list-table tbody tr:last-child td {
@ -9633,6 +9710,20 @@ html.login-page--paused .login-page__flare {
min-height: 28px; min-height: 28px;
} }
@media (max-width: 800px) {
.network-main .pt-list-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.network-main .pt-list-kpi:nth-child(2) {
border-right: 0;
}
.network-main .pt-list-kpi:nth-child(-n+2) {
border-bottom: 1px solid var(--nm-ops-line);
}
}
.network-main .pager.pt-list-pager, .network-main .pager.pt-list-pager,
.network-main .pt-list-pager { .network-main .pt-list-pager {
display: flex; display: flex;
@ -9805,12 +9896,10 @@ html.login-page--paused .login-page__flare {
.ume-page > .ume-entry-grid > .panel { .ume-page > .ume-entry-grid > .panel {
padding: 16px 18px 18px; padding: 16px 18px 18px;
border: 1px solid var(--nm-panel-border); border: 1px solid var(--nm-ops-line);
border-radius: 14px; border-radius: 6px;
background: linear-gradient(180deg, rgba(22, 32, 50, 0.96), rgba(15, 23, 42, 0.92)); background: #0c1521;
box-shadow: box-shadow: none;
0 1px 0 rgba(255, 255, 255, 0.03) inset,
0 10px 28px rgba(0, 0, 0, 0.18);
margin: 0; margin: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -11910,35 +11999,15 @@ html.login-page--paused .login-page__flare {
overflow: hidden; overflow: hidden;
} }
/* Business compare workspace: compact, flat ops surfaces. */ /* Business compare workspace: compact, flat ops surfaces.
Panel / table / control chrome inherits from .network-main ops defaults. */
.bs-cmp-workspace, .bs-cmp-workspace,
.bs-cmp-job-modal, .bs-cmp-job-modal,
.bs-cmp-create { .bs-cmp-create {
--cmp-line: #2b3748; --cmp-line: var(--nm-ops-line, #2b3748);
--cmp-muted: #9aa9bc; --cmp-muted: var(--nm-ops-muted, #9aa9bc);
--cmp-surface: #111c2a; --cmp-surface: var(--nm-ops-surface, #111c2a);
--cmp-accent: #6ba9ec; --cmp-accent: var(--nm-ops-accent, #6ba9ec);
}
.panel.bs-cmp-workspace {
border-radius: 6px;
border-color: var(--cmp-line);
background: #0c1521;
}
.bs-cmp-workspace > .pt-list > .pt-list-table-wrap {
border-radius: 4px;
border-color: var(--cmp-line);
}
.bs-cmp-workspace > .pt-list > .pt-list-table-wrap .pt-list-table td {
border-bottom: 1px solid var(--cmp-line);
}
.bs-cmp-workspace .button,
.bs-cmp-workspace .input {
border-radius: 4px;
box-shadow: none;
} }
.bs-cmp-workspace .bs-cmp-runs__delete { .bs-cmp-workspace .bs-cmp-runs__delete {
@ -12498,50 +12567,27 @@ html.login-page--paused .login-page__flare {
.bs-cmp-job-modal .is-indeterminate { animation: none; } .bs-cmp-job-modal .is-indeterminate { animation: none; }
} }
/* Business monitoring uses the same compact operations surfaces as comparison. */ /* Business monitoring: modal + task chrome; list panel inherits .network-main ops. */
.bs-monitor-workspace, .bs-monitor-workspace,
.bs-monitor-modal { .bs-monitor-modal {
--mon-line: #2b3748; --mon-line: var(--nm-ops-line, #2b3748);
--mon-muted: #9aa9bc; --mon-muted: var(--nm-ops-muted, #9aa9bc);
--mon-accent: #6ba9ec; --mon-accent: var(--nm-ops-accent, #6ba9ec);
} }
.panel.bs-monitor-workspace,
.bs-monitor-modal .modal__dialog { .bs-monitor-modal .modal__dialog {
border-color: var(--mon-line); border-color: var(--mon-line);
border-radius: 6px; border-radius: 6px;
background: #0c1521; background: #0c1521;
} }
.bs-monitor-workspace .button,
.bs-monitor-modal .button, .bs-monitor-modal .button,
.bs-monitor-workspace .input,
.bs-monitor-modal .input, .bs-monitor-modal .input,
.bs-monitor-modal .ui-field__select { .bs-monitor-modal .ui-field__select {
border-radius: 4px; border-radius: 4px;
box-shadow: none; box-shadow: none;
} }
.bs-monitor-workspace .pt-list-kpis {
gap: 0;
border: 1px solid var(--mon-line);
border-radius: 4px;
overflow: hidden;
background: #111c2a;
}
.bs-monitor-workspace .pt-list-kpi {
border: 0;
border-right: 1px solid var(--mon-line);
border-radius: 0;
background: transparent;
}
.bs-monitor-workspace .pt-list-kpi:last-child { border-right: 0; }
.bs-monitor-workspace .pt-list-kpi__value { font-family: ui-monospace, Consolas, monospace; }
.bs-monitor-workspace .pt-list-kpi__label { font-size: 11px; color: var(--mon-muted); }
.bs-monitor-workspace .filter-inline,
.bs-monitor-modal .filter-inline { .bs-monitor-modal .filter-inline {
flex-wrap: wrap; flex-wrap: wrap;
border-radius: 4px; border-radius: 4px;
@ -12549,18 +12595,13 @@ html.login-page--paused .login-page__flare {
.bs-monitor-workspace > .pt-list > .pt-list-table-wrap, .bs-monitor-workspace > .pt-list > .pt-list-table-wrap,
.bs-monitor-modal .pt-list-table-wrap { .bs-monitor-modal .pt-list-table-wrap {
border-color: var(--mon-line);
border-radius: 4px;
min-width: 0; min-width: 0;
} }
.bs-monitor-workspace > .pt-list > .pt-list-table-wrap .pt-list-table { min-width: 1120px; } .bs-monitor-workspace > .pt-list > .pt-list-table-wrap .pt-list-table { min-width: 1120px; }
.bs-monitor-batches .pt-list-table { min-width: 1100px; } .bs-monitor-batches .pt-list-table { min-width: 1100px; }
.bs-monitor-workspace .pt-list-table th,
.bs-monitor-modal .pt-list-table th { background: #162232; font-size: 11px; } .bs-monitor-modal .pt-list-table th { background: #162232; font-size: 11px; }
.bs-monitor-workspace .pt-list-table td,
.bs-monitor-modal .pt-list-table td { border-bottom-color: var(--mon-line); } .bs-monitor-modal .pt-list-table td { border-bottom-color: var(--mon-line); }
.bs-monitor-workspace .pt-list-table td { font-size: 12px; }
.bs-monitor-workspace .pt-list-task-name + .muted { font-size: 11px; } .bs-monitor-workspace .pt-list-task-name + .muted { font-size: 11px; }
.bs-monitor-heading { display: flex; flex-direction: column; gap: 5px; min-width: 0; } .bs-monitor-heading { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
@ -12626,9 +12667,6 @@ html.login-page--paused .login-page__flare {
.bs-monitor-modal .bs-sheet-count { margin-left: auto; } .bs-monitor-modal .bs-sheet-count { margin-left: auto; }
@media (max-width: 800px) { @media (max-width: 800px) {
.bs-monitor-workspace .pt-list-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.bs-monitor-workspace .pt-list-kpi:nth-child(2) { border-right: 0; }
.bs-monitor-workspace .pt-list-kpi:nth-child(-n+2) { border-bottom: 1px solid var(--mon-line); }
.bs-monitor-task .bs-schedule-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } .bs-monitor-task .bs-schedule-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@ -15375,50 +15413,50 @@ option {
} }
.app-main .filter-inline, .app-main .filter-inline,
.network-main .filter-inline,
.ne-page .filter-inline, .ne-page .filter-inline,
.ume-page .filter-inline, .ume-page .filter-inline,
.system-page .filter-inline { .system-page .filter-inline,
.network-main .filter-inline {
background: rgba(8, 13, 24, 0.45) !important; background: rgba(8, 13, 24, 0.45) !important;
border-color: rgba(148, 163, 184, 0.16) !important; border-color: var(--nm-ops-line) !important;
} }
.app-main .filter-inline input, .app-main .filter-inline input,
.app-main .filter-inline select, .app-main .filter-inline select,
.network-main .filter-inline input,
.network-main .filter-inline select,
.ne-page .filter-inline input, .ne-page .filter-inline input,
.ne-page .filter-inline select, .ne-page .filter-inline select,
.ume-page .filter-inline input, .ume-page .filter-inline input,
.ume-page .filter-inline select, .ume-page .filter-inline select,
.system-page .filter-inline input, .system-page .filter-inline input,
.system-page .filter-inline select { .system-page .filter-inline select,
.network-main .filter-inline input,
.network-main .filter-inline select {
background: #0b1220 !important; background: #0b1220 !important;
border-color: rgba(148, 163, 184, 0.24) !important; border-color: var(--nm-ops-line) !important;
color: #e2e8f0 !important; color: #e2e8f0 !important;
} }
.app-main .pt-list-table-wrap, .app-main .pt-list-table-wrap,
.network-main .pt-list-table-wrap,
.ne-page .pt-list-table-wrap, .ne-page .pt-list-table-wrap,
.ume-page .pt-list-table-wrap, .ume-page .pt-list-table-wrap,
.system-page .pt-list-table-wrap, .system-page .pt-list-table-wrap,
.network-main .pt-list-table-wrap,
.pt-list-table-wrap { .pt-list-table-wrap {
border-color: rgba(148, 163, 184, 0.14) !important; border-color: var(--nm-ops-line) !important;
background: rgba(8, 13, 24, 0.28); background: rgba(8, 13, 24, 0.28);
} }
.app-main .pt-list-table thead th, .app-main .pt-list-table thead th,
.network-main .pt-list-table thead th,
.ne-page .pt-list-table thead th, .ne-page .pt-list-table thead th,
.ume-page .pt-list-table thead th, .ume-page .pt-list-table thead th,
.system-page .pt-list-table thead th, .system-page .pt-list-table thead th,
.network-main .pt-list-table thead th,
.pt-list-table thead th, .pt-list-table thead th,
.app-main .data-table thead th, .app-main .data-table thead th,
.data-table thead th { .data-table thead th {
background: rgba(15, 23, 42, 0.92) !important; background: #162232 !important;
color: #94a3b8 !important; color: #94a3b8 !important;
border-bottom-color: rgba(148, 163, 184, 0.14); border-bottom-color: var(--nm-ops-line);
} }
.app-main .pt-list-table tbody tr:hover td, .app-main .pt-list-table tbody tr:hover td,

View file

@ -173,6 +173,16 @@ export function ApiTokensPage() {
onError: (e) => showError(String(e instanceof Error ? e.message : e)), onError: (e) => showError(String(e instanceof Error ? e.message : e)),
}); });
const deleteMut = useMutation({
mutationFn: (id: string) => apiDelete(`/v1/api-tokens/${encodeURIComponent(id)}/purge`),
onSuccess: async () => {
showOk(t("auth.tokenDeleted"));
setEditingRow(null);
await qc.invalidateQueries({ queryKey: ["apiTokens"] });
},
onError: (e) => showError(String(e instanceof Error ? e.message : e)),
});
const onCreate = (e: FormEvent) => { const onCreate = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setCreatedPlain(""); setCreatedPlain("");
@ -367,6 +377,16 @@ export function ApiTokensPage() {
> >
{t("auth.revokeToken")} {t("auth.revokeToken")}
</Button> </Button>
<Button
size="sm"
variant="danger"
isDisabled={deleteMut.isPending}
onPress={() => {
if (window.confirm(t("auth.deleteConfirm"))) deleteMut.mutate(row.id);
}}
>
{t("auth.deleteToken")}
</Button>
</div> </div>
</td> </td>
</tr> </tr>

View file

@ -78,84 +78,89 @@ export function SessionsPage() {
const items = sessionsQuery.data?.items || []; const items = sessionsQuery.data?.items || [];
return ( return (
<div className="page"> <div className="page-stack system-page">
<header className="page-header"> <section className="panel">
<h1>{t("auth.sessionsTitle")}</h1> <div className="panel__toolbar">
<p className="panel__hint">{t("auth.sessionsHint")}</p> <h2>{t("auth.sessionsTitle")}</h2>
</header>
<div className="panel">
<div className="filter-inline" style={{ marginBottom: 12 }}>
<Button
size="sm"
variant="secondary"
isDisabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
onPress={() => revokeOthersMut.mutate()}
>
{t("auth.revokeOtherSessions")}
</Button>
<Button
size="sm"
variant="secondary"
onPress={() => void sessionsQuery.refetch()}
isDisabled={sessionsQuery.isFetching}
>
{t("common.refresh")}
</Button>
</div> </div>
<p className="panel__hint">{t("auth.sessionsHint")}</p>
<div className="pt-list">
<div className="filter-inline">
<Button
size="sm"
variant="secondary"
isDisabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
onPress={() => revokeOthersMut.mutate()}
>
{t("auth.revokeOtherSessions")}
</Button>
<Button
size="sm"
variant="secondary"
onPress={() => void sessionsQuery.refetch()}
isDisabled={sessionsQuery.isFetching}
>
{t("common.refresh")}
</Button>
</div>
{sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null} {sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!items.length && !sessionsQuery.isLoading ? ( {!items.length && !sessionsQuery.isLoading ? (
<p className="muted">{t("auth.sessionsEmpty")}</p> <div className="pt-list-empty">
) : ( <p className="muted">{t("auth.sessionsEmpty")}</p>
<table className="data-table"> </div>
<thead> ) : (
<tr> <div className="pt-list-table-wrap">
<th>{t("auth.colSession")}</th> <table className="data-table pt-list-table">
<th>{t("auth.colIp")}</th> <thead>
<th>{t("auth.colClient")}</th> <tr>
<th>{t("auth.colLastSeen")}</th> <th>{t("auth.colSession")}</th>
<th>{t("auth.colCreated")}</th> <th>{t("auth.colIp")}</th>
<th>{t("auth.actions")}</th> <th>{t("auth.colClient")}</th>
</tr> <th>{t("auth.colLastSeen")}</th>
</thead> <th>{t("auth.colCreated")}</th>
<tbody> <th>{t("auth.actions")}</th>
{items.map((row) => ( </tr>
<tr key={row.id}> </thead>
<td> <tbody>
<code title={row.id}>{row.id.slice(0, 10)}…</code> {items.map((row) => (
{row.current ? ( <tr key={row.id}>
<span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}> <td>
{t("auth.sessionCurrent")} <code title={row.id}>{row.id.slice(0, 10)}…</code>
</span> {row.current ? (
) : null} <span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}>
</td> {t("auth.sessionCurrent")}
<td> </span>
<code>{row.client_ip || "—"}</code> ) : null}
</td> </td>
<td title={row.user_agent || undefined}>{summarizeUa(row.user_agent)}</td> <td>
<td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td> <code>{row.client_ip || "—"}</code>
<td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td> </td>
<td> <td title={row.user_agent || undefined}>{summarizeUa(row.user_agent)}</td>
<Button <td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td>
size="sm" <td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td>
variant="secondary" <td>
isDisabled={revokeMut.isPending} <Button
onPress={() => { size="sm"
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return; variant="secondary"
revokeMut.mutate(row.id); isDisabled={revokeMut.isPending}
}} onPress={() => {
> if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
{t("auth.revokeSession")} revokeMut.mutate(row.id);
</Button> }}
</td> >
</tr> {t("auth.revokeSession")}
))} </Button>
</tbody> </td>
</table> </tr>
)} ))}
</div> </tbody>
</table>
</div>
)}
</div>
</section>
</div> </div>
); );
} }

View file

@ -8,7 +8,6 @@ export function AuditLayout() {
return ( return (
<div className="network-shell audit-shell"> <div className="network-shell audit-shell">
<aside className="network-nav" aria-label={t("audit.title")}> <aside className="network-nav" aria-label={t("audit.title")}>
<div className="network-nav__brand">{t("audit.title")}</div>
<nav className="network-nav__scroll"> <nav className="network-nav__scroll">
<ul className="network-nav__list network-nav__list--flat"> <ul className="network-nav__list network-nav__list--flat">
{AUDIT_NAV.map((item) => ( {AUDIT_NAV.map((item) => (
@ -16,6 +15,7 @@ export function AuditLayout() {
<NavLink <NavLink
to={item.path} to={item.path}
className={({ isActive }) => `network-nav__link${isActive ? " is-active" : ""}`} className={({ isActive }) => `network-nav__link${isActive ? " is-active" : ""}`}
end={item.path === "/audit/tasks"}
> >
{t(item.labelKey)} {t(item.labelKey)}
</NavLink> </NavLink>

View file

@ -55,7 +55,6 @@ export function NetworkLayout() {
return ( return (
<div className="network-shell"> <div className="network-shell">
<aside className="network-nav" aria-label={t("network.title")}> <aside className="network-nav" aria-label={t("network.title")}>
<div className="network-nav__brand">{t("network.title")}</div>
<nav className="network-nav__scroll"> <nav className="network-nav__scroll">
<Accordion <Accordion
className="network-nav__accordion" className="network-nav__accordion"