Build IP Sentinel operations console

This commit is contained in:
Codex
2026-08-09 15:22:28 +03:00
commit cf8c51e3e8
28 changed files with 7068 additions and 0 deletions

90
app/chatgpt-auth.ts Normal file
View File

@@ -0,0 +1,90 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
userId: string;
displayName: string;
email: string;
fullName: string | null;
};
const USER_ID_HEADER = "oai-authenticated-user-id";
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const userId = requestHeaders.get(USER_ID_HEADER);
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!userId || !email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
userId,
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}

51
app/globals.css Normal file
View File

@@ -0,0 +1,51 @@
@import "tailwindcss";
:root { --ink:#18202b; --muted:#6f7885; --line:#e6e8ec; --navy:#172139; --blue:#2764e7; --canvas:#f5f6f8; }
* { box-sizing:border-box; }
body { margin:0; background:var(--canvas); color:var(--ink); font-family:var(--font-geist-sans),Arial,sans-serif; }
button,input { font:inherit; }
button { cursor:pointer; }
.topbar { height:68px; display:flex; align-items:center; padding:0 34px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:10; }
.brand { display:flex; align-items:center; gap:10px; font-size:17px; font-weight:760; letter-spacing:-.03em; width:225px; }
.brandMark { display:grid; place-items:center; width:31px; height:31px; background:var(--navy); color:#fff; border-radius:8px; font-size:11px; letter-spacing:.02em; }
nav { display:flex; align-self:stretch; gap:30px; }
nav button { border:0; background:none; color:#7a818b; font-size:13px; font-weight:600; position:relative; padding:0; }
nav button:hover, nav .navActive { color:var(--ink); }
nav .navActive:after { content:""; position:absolute; height:2px; background:var(--blue); bottom:0; left:0; right:0; }
.topActions { margin-left:auto; display:flex; align-items:center; gap:10px; font-size:12px; }
.topActions small { display:block; color:#8a919a; margin-top:2px; }
.iconButton { border:0; background:transparent; color:#c2c7ce; font-size:9px; margin-right:8px; }
.avatar { width:33px; height:33px; display:grid; place-items:center; background:#dce8ff; color:#2350a5; border-radius:50%; font-size:11px; font-weight:750; }
.workspace { max-width:1360px; margin:0 auto; padding:35px 34px 55px; }
.hero { display:flex; align-items:flex-end; justify-content:space-between; margin-bottom:24px; }
.eyebrow { color:#476386!important; letter-spacing:.13em; font-size:10px!important; font-weight:750; margin-bottom:9px!important; }
.hero h1 { margin:0; font-size:30px; line-height:1.15; letter-spacing:-.045em; }
.hero p { color:var(--muted); margin:8px 0 0; font-size:13px; }
.health { display:flex; align-items:center; gap:11px; background:#fff; padding:11px 15px; border:1px solid var(--line); border-radius:10px; font-size:11px; box-shadow:0 3px 12px #1f293708; }
.health small { display:block; color:#8b929c; margin-top:3px; }.pulse { width:8px; height:8px; background:#29a36a; border-radius:50%; box-shadow:0 0 0 5px #29a36a18; }
.lookup { display:flex; align-items:center; gap:15px; background:var(--navy); color:#fff; padding:20px 22px; border-radius:12px; box-shadow:0 9px 24px #1721391f; }
.lookupIcon { width:36px; height:36px; border:1px solid #ffffff26; border-radius:8px; display:grid; place-items:center; font-size:24px; }
.lookupCopy { min-width:235px; }.lookupCopy label { display:block; font-size:13px; font-weight:680; }.lookupCopy span { display:block; font-size:10px; color:#aab3c5; margin-top:4px; }
.lookup input { min-width:220px; flex:1; height:42px; background:#273149; border:1px solid #ffffff1b; color:#fff; border-radius:7px; padding:0 15px; outline:none; font-family:var(--font-geist-mono); font-size:12px; }
.lookup input:focus { border-color:#6c93f0; box-shadow:0 0 0 3px #2764e730; }.lookup input::placeholder { color:#8290a8; }
.primary { height:42px; border:0; padding:0 18px; border-radius:7px; background:#fff; color:#172139; font-weight:700; font-size:12px; }.primary span { margin-left:14px; color:var(--blue); }
.metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin:17px 0; }
.metrics article { background:#fff; border:1px solid var(--line); border-radius:10px; padding:16px 17px; }
.metricTop { display:flex; justify-content:space-between; align-items:center; color:#78818d; font-size:9px; letter-spacing:.09em; font-weight:700; }
.metricTop i { font-style:normal; display:grid; place-items:center; width:26px; height:26px; border-radius:7px; font-size:14px; }.blue{color:#3374e8;background:#eaf1ff}.violet{color:#6c55c7;background:#f0edff}.red{color:#dc504f;background:#fff0ef}.amber{color:#bb7917;background:#fff5e4}
.metrics article>strong { display:block; font-size:25px; letter-spacing:-.045em; margin:9px 0 5px; }.metrics article p { margin:0; color:#9299a2; font-size:10px; }.metrics b { color:#35946b; }.metrics em { color:#dc5c59; font-style:normal; font-weight:700; }
.mainGrid { display:grid; grid-template-columns:minmax(0,1fr) 320px; gap:14px; align-items:start; }
.panel { background:#fff; border:1px solid var(--line); border-radius:11px; overflow:hidden; }
.panelHead { min-height:67px; padding:15px 18px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }.panel h2 { margin:0; font-size:14px; }.panelHead p { margin:5px 0 0; font-size:10px; color:#89919b; }
.filters { background:#f5f6f8; padding:3px; border-radius:7px; display:flex; }.filters button { border:0; background:transparent; color:#777f8b; border-radius:5px; padding:6px 10px; font-size:9px; }.filters button.active { background:#fff; color:#202936; box-shadow:0 1px 4px #00000012; font-weight:700; }
.tableWrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; } th { padding:10px 14px; background:#fafbfc; text-align:left; color:#8a929d; font-size:8px; letter-spacing:.08em; } td { padding:12px 14px; border-top:1px solid #eff0f2; font-size:11px; } tbody tr { transition:.15s; cursor:pointer; } tbody tr:hover, tbody tr.selected { background:#f7f9fd; } td code,.detail code { font-family:var(--font-geist-mono); font-weight:650; font-size:11px; } td small { display:block; margin-top:4px; color:#949ba4; font-size:9px; } td strong { font-size:10px; }
.score { display:inline-grid; place-items:center; width:27px; height:27px; border-radius:50%; font-weight:750; font-size:10px; border:3px solid; }.score.critical{color:#cf4545;border-color:#f0a2a2}.score.elevated{color:#b2761b;border-color:#f2c779}.score.low{color:#28865d;border-color:#8fd2b4}
.status { display:inline-flex; align-items:center; gap:5px; border-radius:20px; padding:5px 8px; font-size:8px; font-weight:750; }.status:before { content:""; width:5px; height:5px; border-radius:50%; }.status.review{color:#9a691e;background:#fff3dd}.status.review:before{background:#d69832}.status.blocked{color:#b63b3c;background:#ffeded}.status.blocked:before{background:#d64d4e}.status.allowed{color:#247e59;background:#e9f7f0}.status.allowed:before{background:#35a574}
.viewAll,.fullReport { width:100%; border:0; border-top:1px solid var(--line); background:#fff; color:#46628e; padding:12px; font-size:9px; font-weight:700; }.viewAll:hover,.fullReport:hover { background:#f7f9fc; }
.detailHead { padding:16px 17px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }.detailHead p { margin:0 0 6px; font-size:8px; color:#8f97a2; letter-spacing:.1em; font-weight:700; }
.riskBlock { display:flex; align-items:center; gap:15px; padding:18px 17px; border-bottom:1px solid var(--line); }.riskRing { width:63px; height:63px; border-radius:50%; border:5px solid; display:flex; align-items:baseline; justify-content:center; padding-top:16px; }.riskRing strong{font-size:20px}.riskRing span{font-size:8px;color:#8a929c}.riskRing.critical{border-color:#ef807a;color:#c43d3a}.riskRing.elevated{border-color:#e6b155;color:#a46a13}.riskRing.low{border-color:#64ba94;color:#277853}.riskBlock p{margin:0;color:#8b929c;font-size:9px}.riskBlock h3{margin:3px 0;font-size:14px}.riskBlock small{font-size:8px;color:#969da6}
.signal { display:flex; justify-content:space-between; align-items:center; margin:0 17px; padding:12px 0; border-bottom:1px solid #eef0f2; }.signal span { font-size:8px; color:#969da6; letter-spacing:.08em; }.signal strong { font-size:9px; max-width:160px; text-align:right; }
.actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:16px 17px; }.actions button { border:1px solid #dfe2e6; background:#fff; border-radius:6px; padding:9px 5px; font-size:9px; font-weight:700; }.actions button:hover { background:#f7f8fa; }.actions .danger { background:#c94747; color:#fff; border-color:#c94747; }.actions .danger:hover { background:#b63e3e; }
.toast { position:fixed; right:28px; bottom:28px; background:#182235; color:#fff; border-radius:9px; padding:12px 17px; box-shadow:0 10px 28px #17213942; font-size:11px; display:flex; gap:9px; align-items:center; }.toast span { color:#62d49e; }
@media(max-width:900px){.topbar{padding:0 18px}.brand{width:auto;margin-right:28px}.topActions>div:last-child{display:none}.workspace{padding:26px 18px}.lookup{flex-wrap:wrap}.lookupCopy{min-width:calc(100% - 55px)}.lookup input{min-width:0}.metrics{grid-template-columns:1fr 1fr}.mainGrid{grid-template-columns:1fr}.detail{order:-1}nav{gap:18px}.health{display:none}}
@media(max-width:620px){nav{display:none}.hero h1{font-size:25px}.lookup input,.primary{width:100%;flex:auto}.metrics{grid-template-columns:1fr 1fr}.metrics article{padding:13px}.metrics article>strong{font-size:21px}.panelHead{align-items:flex-start;gap:12px;flex-direction:column}.filters{width:100%;overflow:auto}.filters button{flex:1;white-space:nowrap}.mainGrid{display:flex;flex-direction:column}.detail,.activity{width:100%}th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3){display:none}.topbar{height:60px}.workspace{padding-top:22px}.hero{margin-bottom:18px}}

16
app/layout.tsx Normal file
View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = {
title: "IP Sentinel — Network Intelligence",
description: "Internal IP reputation and network operations console.",
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html lang="en"><body className={`${geistSans.variable} ${geistMono.variable}`}>{children}</body></html>;
}

93
app/page.tsx Normal file
View File

@@ -0,0 +1,93 @@
"use client";
import { useMemo, useState } from "react";
type Event = { ip: string; country: string; city: string; network: string; score: number; status: "Review" | "Blocked" | "Allowed"; seen: string; requests: string };
const initialEvents: Event[] = [
{ ip: "185.220.101.42", country: "Germany", city: "Falkenstein", network: "AS60729 · Zwiebelfreunde", score: 96, status: "Review", seen: "12 sec ago", requests: "842" },
{ ip: "45.134.26.91", country: "Netherlands", city: "Amsterdam", network: "AS9009 · M247 Europe", score: 88, status: "Blocked", seen: "1 min ago", requests: "391" },
{ ip: "104.28.208.87", country: "United States", city: "San Francisco", network: "AS13335 · Cloudflare", score: 18, status: "Allowed", seen: "3 min ago", requests: "126" },
{ ip: "193.32.162.14", country: "Romania", city: "Bucharest", network: "AS209605 · UAB Host Baltic", score: 73, status: "Review", seen: "7 min ago", requests: "214" },
{ ip: "34.160.111.145", country: "United States", city: "Mountain View", network: "AS396982 · Google Cloud", score: 7, status: "Allowed", seen: "11 min ago", requests: "98" },
];
function riskLabel(score: number) { return score >= 80 ? "Critical" : score >= 60 ? "Elevated" : "Low"; }
export default function Home() {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState("All traffic");
const [events, setEvents] = useState(initialEvents);
const [selected, setSelected] = useState<Event>(initialEvents[0]);
const [notice, setNotice] = useState("");
const visible = useMemo(() => events.filter((e) => filter === "All traffic" || e.status === filter), [events, filter]);
const inspect = () => {
const value = query.trim();
if (!value) return;
const found = events.find((e) => e.ip === value);
if (found) setSelected(found);
else setSelected({ ip: value, country: "Unknown", city: "Unresolved", network: "No network record", score: 42, status: "Review", seen: "Just now", requests: "1" });
setNotice(`Inspection opened for ${value}`);
setTimeout(() => setNotice(""), 2400);
};
const decide = (status: "Blocked" | "Allowed") => {
setSelected((current) => ({ ...current, status }));
setEvents((current) => current.map((event) => event.ip === selected.ip ? { ...event, status } : event));
setNotice(`${selected.ip} ${status.toLowerCase()}`);
setTimeout(() => setNotice(""), 2400);
};
return (
<main>
<header className="topbar">
<div className="brand"><span className="brandMark">IP</span><span>Sentinel</span></div>
<nav aria-label="Primary navigation">
<button className="navActive">Overview</button><button>Investigations</button><button>Rules</button><button>Reports</button>
</nav>
<div className="topActions"><button className="iconButton" aria-label="Notifications"></button><div className="avatar">MK</div><div><strong>Maya Kim</strong><small>Security Ops</small></div></div>
</header>
<div className="workspace">
<section className="hero">
<div><p className="eyebrow">NETWORK INTELLIGENCE</p><h1>Good morning, Maya.</h1><p>Heres what your perimeter has seen in the last 24 hours.</p></div>
<div className="health"><span className="pulse"/><div><strong>All systems operational</strong><small>Last sync 18 seconds ago</small></div></div>
</section>
<section className="lookup" aria-label="IP address lookup">
<div className="lookupIcon"></div><div className="lookupCopy"><label htmlFor="ip">Inspect an IP address</label><span>Reputation, location, network and recent activity</span></div>
<input id="ip" value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && inspect()} placeholder="Enter IPv4 or IPv6 address" />
<button className="primary" onClick={inspect}>Inspect IP <span></span></button>
</section>
<section className="metrics" aria-label="Traffic summary">
<article><div className="metricTop"><span>REQUESTS SCANNED</span><i className="blue"></i></div><strong>2.48M</strong><p><b>+12.4%</b> from yesterday</p></article>
<article><div className="metricTop"><span>UNIQUE ADDRESSES</span><i className="violet"></i></div><strong>18,294</strong><p><b>+3.1%</b> from yesterday</p></article>
<article><div className="metricTop"><span>THREATS BLOCKED</span><i className="red">!</i></div><strong>1,847</strong><p><em>8.2%</em> from yesterday</p></article>
<article><div className="metricTop"><span>REVIEW QUEUE</span><i className="amber"></i></div><strong>24</strong><p><b>7 critical</b> need attention</p></article>
</section>
<div className="mainGrid">
<section className="panel activity">
<div className="panelHead"><div><h2>Recent activity</h2><p>Addresses with notable behavior</p></div><div className="filters">{["All traffic","Review","Blocked","Allowed"].map((name) => <button key={name} className={filter === name ? "active" : ""} onClick={() => setFilter(name)}>{name}</button>)}</div></div>
<div className="tableWrap"><table><thead><tr><th>IP ADDRESS</th><th>LOCATION / NETWORK</th><th>REQUESTS</th><th>RISK</th><th>STATUS</th><th></th></tr></thead><tbody>{visible.map((event) => <tr key={event.ip} className={selected.ip === event.ip ? "selected" : ""} onClick={() => setSelected(event)}><td><code>{event.ip}</code><small>{event.seen}</small></td><td><strong>{event.city}, {event.country}</strong><small>{event.network}</small></td><td>{event.requests}</td><td><span className={`score ${riskLabel(event.score).toLowerCase()}`}>{event.score}</span></td><td><span className={`status ${event.status.toLowerCase()}`}>{event.status}</span></td><td></td></tr>)}</tbody></table></div>
<button className="viewAll">View all investigations </button>
</section>
<aside className="panel detail">
<div className="detailHead"><div><p>SELECTED ADDRESS</p><code>{selected.ip}</code></div><span className={`status ${selected.status.toLowerCase()}`}>{selected.status}</span></div>
<div className="riskBlock"><div className={`riskRing ${riskLabel(selected.score).toLowerCase()}`}><strong>{selected.score}</strong><span>/100</span></div><div><p>Risk assessment</p><h3>{riskLabel(selected.score)} risk</h3><small>Based on 14 intelligence signals</small></div></div>
<div className="signal"><span>TOR EXIT NODE</span><strong>{selected.score > 80 ? "Detected" : "Not detected"}</strong></div>
<div className="signal"><span>ABUSE REPORTS</span><strong>{selected.score > 80 ? "47 in 30 days" : "None recent"}</strong></div>
<div className="signal"><span>NETWORK</span><strong>{selected.network.split(" · ")[0]}</strong></div>
<div className="signal"><span>LOCATION</span><strong>{selected.city}, {selected.country}</strong></div>
<div className="actions"><button className="danger" onClick={() => decide("Blocked")}>Block address</button><button onClick={() => decide("Allowed")}>Add to allowlist</button></div>
<button className="fullReport">Open full investigation </button>
</aside>
</div>
</div>
{notice && <div className="toast" role="status"><span></span>{notice}</div>}
</main>
);
}