roglag / web /index.html
AI Bot
feat: hamburger nav menu on mobile (a11y: aria-expanded, Esc, outside-click)
bd69aea
Raw
History Blame Contribute Delete
95.2 kB
<!doctype html>
<html lang="th">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ROGLAG | Threat Intelligence</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='15' fill='%23071120'/%3E%3Ccircle cx='32' cy='32' r='18' fill='none' stroke='%2314b8a6' stroke-width='3.2'/%3E%3Cpath d='M32 32 L32 14 A18 18 0 0 1 47.6 23 Z' fill='%2314b8a6' opacity='0.4'/%3E%3Ccircle cx='32' cy='32' r='2.2' fill='%235eead4'/%3E%3Ccircle cx='42.7' cy='23.3' r='2.9' fill='%237dd3fc'/%3E%3C/svg%3E" />
<style>
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Thai:wght@400;500;600;700&family=IBM+Plex+Mono:wght@500;600;700&display=swap");
:root {
--bg: #060b16;
--surface: #0d1726;
--soft: #111e31;
--nav: #07111f;
--nav-2: #10223a;
--text: #e6eef8;
--muted: #a3b7cc;
--line: rgba(148, 163, 184, 0.22);
--teal: #14b8a6;
--teal-soft: #dff8f5;
--critical: #be123c;
--critical-soft: rgba(190, 18, 60, 0.12);
--high: #f97316;
--medium: #ca8a04;
--low: #64748b;
--ok: #16a34a;
--shadow: 0 6px 18px rgba(0, 0, 0, 0.22);
--radius: 14px;
}
* { box-sizing: border-box; }
html {
scroll-behavior: smooth;
background: var(--bg);
}
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: "IBM Plex Sans Thai", system-ui, sans-serif;
background:
linear-gradient(180deg, #050916 0%, #081320 42%, #07111f 100%);
background-color: #060b16;
}
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
:where(button, a, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid #5eead4;
outline-offset: 2px;
border-radius: 8px;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
.navbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
min-height: 72px;
padding: 0 24px;
color: #f8fafc;
background: rgba(7, 17, 31, 0.92);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
backdrop-filter: blur(18px);
}
.navbar::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(94, 234, 212, 0.55), transparent);
pointer-events: none;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 210px;
}
.logo {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 12px;
background:
radial-gradient(120% 120% at 30% 20%, rgba(94, 234, 212, 0.18), transparent 60%),
linear-gradient(150deg, #0c2034 0%, #0a3140 55%, #0c2034 100%);
border: 1px solid rgba(94, 234, 212, 0.32);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 6px 18px rgba(2, 12, 20, 0.55);
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
}
.brand:hover .logo {
transform: translateY(-1px);
border-color: rgba(94, 234, 212, 0.55);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 10px 24px rgba(20, 184, 166, 0.28);
}
.logo svg { width: 30px; height: 30px; display: block; }
.logoSweep {
transform-origin: 32px 32px;
animation: radarSweep 3.6s linear infinite;
}
.logoBlip {
transform-box: fill-box;
transform-origin: center;
animation: blipPulse 1.8s ease-in-out infinite;
}
@keyframes radarSweep {
to { transform: rotate(360deg); }
}
@keyframes blipPulse {
0%, 100% { opacity: 0.5; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.15); }
}
@media (prefers-reduced-motion: reduce) {
.logoSweep, .logoBlip { animation: none; }
}
.brand strong {
display: block;
font-family: "IBM Plex Mono", monospace;
font-size: 18px;
letter-spacing: 1.5px;
color: #f1f6fc;
}
.brand span {
display: block;
color: #b7c7d8;
font-size: 13px;
letter-spacing: 0.3px;
}
.navTabs {
display: flex;
gap: 6px;
padding: 5px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
}
.navTab {
min-height: 38px;
padding: 0 14px;
color: #d7e2ee;
background: transparent;
border: 0;
border-radius: 999px;
font-weight: 700;
transition: background 0.2s ease, color 0.2s ease;
}
.navTab:hover:not(.active) {
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
}
.navTab.active {
color: #0f2742;
background: #ffffff;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
.navToggle {
display: none;
}
.navActions {
display: flex;
align-items: center;
gap: 10px;
}
.statusPill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 12px;
color: #d7e2ee;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
font-size: 13px;
white-space: nowrap;
}
.alertButton {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 0 12px;
color: #fff;
background: rgba(225, 29, 72, 0.18);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
font-weight: 800;
white-space: nowrap;
}
.alertButton.active {
background: var(--critical);
border-color: rgba(255, 255, 255, 0.2);
}
.alertDot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #fecdd3;
}
.alertButton.active .alertDot {
background: #fff;
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.18);
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
}
.button {
min-height: 40px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 10px;
color: var(--text);
background: rgba(255, 255, 255, 0.06);
font-weight: 700;
transition: transform 0.15s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(94, 234, 212, 0.4);
}
.button:active:not(:disabled) {
transform: translateY(1px);
}
.button.primary {
color: #04141f;
background: var(--teal);
border-color: var(--teal);
}
.button.primary:hover:not(:disabled) {
background: #2dd4bf;
border-color: #2dd4bf;
box-shadow: 0 8px 20px rgba(20, 184, 166, 0.32);
}
.button:disabled {
cursor: wait;
opacity: 0.72;
}
.button.navButton {
color: #0f2742;
background: #f8fafc;
border-color: transparent;
}
.landingHero {
position: relative;
min-height: calc(100vh - 72px);
overflow: hidden;
isolation: isolate;
background:
linear-gradient(115deg, rgba(6, 11, 22, 0.2) 0%, rgba(6, 11, 22, 0.78) 46%, rgba(7, 17, 31, 0.92) 100%),
#040814;
}
#globeCanvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
z-index: 0;
}
.heroOverlay {
position: absolute;
inset: 0;
z-index: 1;
background:
linear-gradient(180deg, rgba(6, 11, 22, 0.08), rgba(6, 11, 22, 0.14) 62%, #060b16 100%),
repeating-linear-gradient(90deg, transparent 0 64px, rgba(255, 255, 255, 0.025) 65px 66px);
pointer-events: none;
}
.landingContent {
position: relative;
z-index: 2;
width: min(1380px, 100%);
min-height: calc(100vh - 72px);
display: grid;
align-content: center;
gap: 26px;
margin: 0 auto;
padding: 44px 22px 82px;
}
.heroKicker {
display: inline-flex;
align-items: center;
gap: 9px;
width: fit-content;
min-height: 34px;
padding: 0 12px;
color: #ccfbf1;
background: rgba(20, 184, 166, 0.1);
border: 1px solid rgba(20, 184, 166, 0.26);
border-radius: 999px;
font-family: "IBM Plex Mono", monospace;
font-size: 13px;
font-weight: 800;
}
.pulseDot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--teal);
box-shadow: 0 0 0 0 rgba(20, 184, 166, 0.5);
animation: pulseDot 1.7s ease infinite;
}
.landingTitle {
max-width: 760px;
margin: 0;
color: #f8fafc;
font-size: clamp(44px, 8vw, 112px);
line-height: 0.86;
letter-spacing: 0;
}
.landingCopy {
max-width: 660px;
margin: 0;
color: #b9c9dc;
font-size: clamp(17px, 2vw, 24px);
line-height: 1.55;
}
.heroControls {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.heroButton {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 0 18px;
color: #04111f;
background: #5eead4;
border: 1px solid rgba(94, 234, 212, 0.7);
border-radius: 12px;
font-weight: 900;
text-decoration: none;
box-shadow: 0 10px 24px rgba(2, 12, 20, 0.4);
}
.heroButton.secondary {
color: #dcecff;
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.13);
box-shadow: none;
}
.heroStats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 160px));
gap: 10px;
margin-top: 8px;
}
.heroStat {
padding: 12px;
color: #dcecff;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
backdrop-filter: blur(12px);
}
.heroStat span {
display: block;
color: #8fa5bc;
font-size: 13px;
font-weight: 800;
}
.heroStat strong {
display: block;
margin-top: 4px;
font-family: "IBM Plex Mono", monospace;
font-size: 22px;
}
.scrollCue {
position: absolute;
left: 50%;
bottom: 24px;
z-index: 2;
width: 30px;
height: 46px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 999px;
transform: translateX(-50%);
}
.scrollCue::after {
content: "";
position: absolute;
top: 9px;
left: 50%;
width: 5px;
height: 9px;
border-radius: 999px;
background: #5eead4;
transform: translateX(-50%);
animation: scrollCue 1.7s ease-in-out infinite;
}
@keyframes pulseDot {
70% { box-shadow: 0 0 0 11px rgba(20, 184, 166, 0); }
100% { box-shadow: 0 0 0 0 rgba(20, 184, 166, 0); }
}
@keyframes scrollCue {
0%, 100% { transform: translate(-50%, 0); opacity: 0.45; }
50% { transform: translate(-50%, 13px); opacity: 1; }
}
.page {
width: min(1380px, 100%);
margin: 0 auto;
padding: 22px;
}
.view { display: none; }
.view.active { display: block; }
.genHead { margin-bottom: 14px; }
.genRow { display: flex; gap: 10px; margin: 10px 0; }
.genRow label { flex: 1; display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted, #9fb3c8); }
.genRow input, .genRow select, .genFile {
width: 100%; background: rgba(8, 16, 28, 0.6); border: 1px solid rgba(94, 234, 212, 0.18);
color: #e8f1f8; border-radius: 9px; padding: 8px 10px; font-size: 13px;
}
.genFile { margin: 8px 0; }
#generatorView .card .button { margin-top: 8px; width: 100%; }
#generatorView .card small { display: block; margin-top: 8px; }
.logMonitor {
height: 420px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px;
background: #05090f; border: 1px solid rgba(94, 234, 212, 0.12); border-radius: 10px; padding: 10px 12px;
font-family: "IBM Plex Mono", monospace; font-size: 13px; line-height: 1.5;
}
.logMonitor .logRow { display: flex; gap: 10px; padding: 3px 6px; border-radius: 6px; white-space: nowrap; }
.logMonitor .logRow:hover { background: rgba(255, 255, 255, 0.03); }
.logMonitor .logRow.new { animation: logFlash 0.7s ease; }
@keyframes logFlash { from { background: rgba(94, 234, 212, 0.14); } to { background: transparent; } }
.logMonitor .lt { color: #7286a0; flex: 0 0 auto; }
.logMonitor .lsev { flex: 0 0 64px; font-weight: 700; }
.logMonitor .lmsg { color: #b8c7da; overflow: hidden; text-overflow: ellipsis; }
.logMonitor .src { color: #8499b0; flex: 0 0 auto; }
.sev-critical { color: #f87171; } .sev-high { color: #fb923c; }
.sev-medium { color: #fbbf24; } .sev-info { color: #97a8c0; }
#generatorView .button.danger { background: rgba(248, 113, 113, 0.14); border-color: rgba(248, 113, 113, 0.42); color: #fca5a5; }
#logSort { background: rgba(8, 16, 28, 0.6); border: 1px solid rgba(94, 234, 212, 0.18); color: #e8f1f8; border-radius: 8px; padding: 6px 10px; font-size: 13px; }
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: end;
margin-bottom: 18px;
}
.hero.compact {
grid-template-columns: 1fr;
justify-items: end;
}
.eyebrow {
display: block;
margin-bottom: 6px;
color: var(--teal);
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
}
h1, h2, h3, p { margin-top: 0; }
h1 {
margin-bottom: 6px;
font-size: clamp(28px, 3.7vw, 48px);
line-height: 1.08;
}
h2 {
margin-bottom: 4px;
font-size: 26px;
}
h3 {
margin-bottom: 4px;
font-size: 18px;
}
.subtle {
color: var(--muted);
}
.cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.alertBanner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
margin-bottom: 16px;
padding: 15px 16px;
background: linear-gradient(90deg, rgba(120, 18, 40, 0.55), rgba(120, 18, 40, 0.3));
border: 1px solid rgba(190, 18, 60, 0.4);
border-radius: 12px;
}
.alertBanner[hidden] {
display: none;
}
.alertBanner strong {
display: block;
margin-bottom: 2px;
color: #ffffff;
}
.alertBanner p {
margin: 0;
color: #ffe1e6;
}
.card, .panel {
background: rgba(13, 23, 38, 0.92);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.card {
position: relative;
min-height: 128px;
padding: 17px;
overflow: hidden;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.card::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(94, 234, 212, 0.6), transparent);
opacity: 0;
transition: opacity 0.2s ease;
}
.card:hover {
transform: translateY(-3px);
border-color: rgba(94, 234, 212, 0.32);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.3);
}
.card:hover::before { opacity: 1; }
.card span {
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.card strong {
display: block;
margin-top: 8px;
font-family: "IBM Plex Mono", monospace;
font-size: 34px;
line-height: 1;
}
.card small {
display: block;
margin-top: 10px;
color: var(--muted);
}
.heatBar {
height: 12px;
display: flex;
overflow: hidden;
margin-top: 14px;
border-radius: 999px;
background: #e2e8f0;
}
.heatBar span {
width: 0;
min-width: 0;
transition: flex-basis 0.22s ease;
}
.heatBar span:nth-child(1) { background: var(--critical); }
.heatBar span:nth-child(2) { background: var(--high); }
.heatBar span:nth-child(3) { background: var(--medium); }
.heatBar span:nth-child(4) { background: var(--low); }
.categorySection {
margin-top: 16px;
}
.categorySection .panelHead select {
width: 220px;
}
.categoryGrid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10px;
}
.categoryCard {
position: relative;
min-height: 118px;
padding: 13px;
color: var(--text);
background: rgba(255, 255, 255, 0.045);
border: 1px solid var(--line);
border-radius: 14px;
text-align: left;
box-shadow: var(--shadow);
transition: transform 0.18s ease, background 0.2s ease, border-color 0.2s ease;
}
.categoryCard:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(94, 234, 212, 0.3);
}
.categoryCard::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 5px;
border-radius: 14px 0 0 14px;
background: var(--low);
}
.categoryCard[data-top-severity="critical"]::before { background: var(--critical); }
.categoryCard[data-top-severity="high"]::before { background: var(--high); }
.categoryCard[data-top-severity="medium"]::before { background: var(--medium); }
.categoryCard strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.categoryCard b {
display: block;
margin-top: 12px;
font-family: "IBM Plex Mono", monospace;
font-size: 28px;
line-height: 1;
}
.categoryCard small {
display: block;
margin-top: 8px;
color: var(--muted);
}
.graphPanel {
margin-top: 16px;
overflow: hidden;
background: linear-gradient(180deg, #11243c 0%, #0d1e33 100%);
border-color: rgba(255, 255, 255, 0.08);
}
.graphPanel .panelHead {
color: #eef7ff;
border-color: rgba(255, 255, 255, 0.1);
}
.graphPanel .panelHead p {
color: #a8bdd2;
}
.graphLegend {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.legendItem {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 30px;
padding: 0 10px;
color: #dcecff;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 999px;
font-size: 13px;
font-weight: 800;
}
.legendSwatch {
width: 10px;
height: 10px;
border-radius: 50%;
}
.graphShell {
position: relative;
min-height: 315px;
padding: 0;
}
.graphShell::after {
content: none;
}
.riskGraph {
display: block;
width: 100%;
height: 315px;
}
.riskGraph .gridLine {
stroke: rgba(255, 255, 255, 0.08);
stroke-width: 1;
}
.riskGraph .bar {
rx: 3px;
opacity: 0.92;
transform-origin: bottom;
animation: barRise 0.7s ease both;
}
.riskGraph .criticalBar { fill: var(--critical); filter: drop-shadow(0 0 8px rgba(225, 29, 72, 0.45)); }
.riskGraph .highBar { fill: var(--high); }
.riskGraph .mediumBar { fill: var(--medium); }
.riskGraph .infoBar { fill: #64748b; }
.riskGraph .riskArea {
fill: url(#riskAreaGradient);
opacity: 0.9;
}
.riskGraph .riskLine {
fill: none;
stroke: #5eead4;
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 10px rgba(94, 234, 212, 0.65));
stroke-dasharray: 1100;
stroke-dashoffset: 1100;
animation: drawLine 1.2s ease forwards;
}
.riskGraph .peakDot {
fill: #ffffff;
stroke: var(--critical);
stroke-width: 4;
filter: drop-shadow(0 0 12px rgba(225, 29, 72, 0.65));
}
.riskGraph .axisLabel, .riskGraph .peakLabel {
fill: #b7c9dc;
font: 700 11px "IBM Plex Mono", monospace;
}
.riskGraph .peakLabel {
fill: #ffffff;
}
.graphStats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 0 16px 16px;
}
.graphStat {
padding: 12px;
color: #dcecff;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
}
.graphStat span {
display: block;
color: #9fb3c8;
font-size: 13px;
font-weight: 800;
}
.graphStat strong {
display: block;
margin-top: 5px;
font-family: "IBM Plex Mono", monospace;
font-size: 19px;
}
@keyframes barRise {
from { transform: scaleY(0); opacity: 0; }
to { transform: scaleY(1); opacity: 0.92; }
}
@keyframes drawLine {
to { stroke-dashoffset: 0; }
}
.twoColumn {
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.1fr);
gap: 16px;
}
.panelHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px;
border-bottom: 1px solid var(--line);
}
.panelBody {
padding: 16px;
}
.toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px 180px;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
input, select, textarea {
width: 100%;
min-height: 42px;
color: var(--text);
background: rgba(255, 255, 255, 0.055);
border: 1px solid var(--line);
border-radius: 10px;
outline: none;
padding: 0 12px;
}
textarea {
min-height: 118px;
padding: 12px;
resize: vertical;
line-height: 1.55;
}
input:focus, select:focus, textarea:focus, button:focus-visible {
border-color: var(--teal);
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.18);
}
.incidentList {
display: grid;
gap: 10px;
max-height: 620px;
overflow: auto;
padding: 14px;
}
.incidentButton {
width: 100%;
position: relative;
display: grid;
gap: 8px;
padding: 14px 14px 14px 18px;
color: var(--text);
background: rgba(255, 255, 255, 0.045);
border: 1px solid var(--line);
border-radius: 12px;
text-align: left;
}
.incidentButton::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 5px;
border-radius: 12px 0 0 12px;
background: var(--low);
}
.incidentButton[data-severity="critical"]::before { background: var(--critical); }
.incidentButton[data-severity="high"]::before { background: var(--high); }
.incidentButton[data-severity="medium"]::before { background: var(--medium); }
.incidentButton.selected {
border-color: var(--teal);
background: rgba(20, 184, 166, 0.1);
}
.incidentTop, .incidentMeta, .detailTitle {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.incidentName {
min-width: 0;
}
.incidentName strong {
display: block;
overflow: hidden;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.incidentName small, .incidentMeta {
color: var(--muted);
font-size: 13px;
}
.severity {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
color: #fff;
background: var(--low);
font-size: 13px;
font-weight: 800;
}
.severity.critical { background: var(--critical); }
.severity.high { background: var(--high); }
.severity.medium { color: #3b2f00; background: var(--medium); }
.detailGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 14px 0;
}
.metaBox {
padding: 12px;
background: var(--soft);
border: 1px solid var(--line);
border-radius: 12px;
}
.metaBox span {
display: block;
color: var(--muted);
font-size: 13px;
}
.metaBox strong {
display: block;
overflow-wrap: anywhere;
margin-top: 5px;
font-family: "IBM Plex Mono", monospace;
font-size: 13px;
}
.stateRow {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.stateButton {
min-height: 34px;
padding: 0 11px;
color: var(--muted);
background: rgba(255, 255, 255, 0.045);
border: 1px solid var(--line);
border-radius: 999px;
font-weight: 700;
}
.stateButton.active {
color: #ccfbf1;
background: rgba(20, 184, 166, 0.14);
border-color: var(--teal);
}
.evidenceList {
display: grid;
gap: 8px;
}
.evidenceLine {
display: grid;
grid-template-columns: 145px minmax(0, 1fr);
gap: 10px;
padding: 10px;
background: var(--soft);
border: 1px solid var(--line);
border-radius: 10px;
font-family: "IBM Plex Mono", monospace;
font-size: 13px;
}
.evidenceLine code {
overflow-wrap: anywhere;
}
.assistantBox {
display: grid;
gap: 10px;
}
.answer {
position: relative;
min-height: 190px;
max-height: 440px;
overflow: auto;
padding: 13px;
color: #dcecff;
background: var(--nav);
border-radius: 12px;
line-height: 1.55;
}
.answer h2, .answer h3 {
margin: 16px 0 8px;
color: #ffffff;
}
.answer h2 {
font-size: 18px;
}
.answer h3 {
font-size: 15px;
}
.answer h2:first-child, .answer h3:first-child {
margin-top: 0;
}
.answer p {
margin: 0 0 12px;
}
.answer ul, .answer ol {
margin: 0 0 14px;
padding-left: 22px;
}
.answer li {
margin: 5px 0;
}
.answer code {
padding: 1px 6px;
color: #ccfbf1;
background: rgba(20, 184, 166, 0.14);
border: 1px solid rgba(20, 184, 166, 0.22);
border-radius: 6px;
font-family: "IBM Plex Mono", monospace;
font-size: 0.92em;
}
.answerMeta {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding: 5px 9px;
color: #b8c7d8;
background: rgba(255, 255, 255, 0.07);
border-radius: 999px;
font-family: "IBM Plex Mono", monospace;
font-size: 13px;
}
.citationBlock {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.citationBlock h2 {
font-size: 15px;
}
.citationItem {
margin-top: 8px;
padding: 10px;
overflow-wrap: anywhere;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 10px;
font-family: "IBM Plex Mono", monospace;
font-size: 13px;
}
.citationItem strong {
display: block;
margin-bottom: 4px;
color: #ffffff;
}
.answer.loading {
min-height: 250px;
overflow: hidden;
white-space: normal;
background:
linear-gradient(90deg, rgba(20, 184, 166, 0.12), transparent 36%, rgba(20, 184, 166, 0.08)),
var(--nav);
}
.answer.loading::before {
content: "";
position: absolute;
inset: 0;
background:
linear-gradient(180deg, transparent 0, rgba(20, 184, 166, 0.16) 48%, transparent 100%),
repeating-linear-gradient(180deg, transparent 0 24px, rgba(255, 255, 255, 0.045) 25px 26px);
animation: scanEvidence 1.8s linear infinite;
pointer-events: none;
}
.loadingDeck {
position: relative;
z-index: 1;
display: grid;
gap: 14px;
}
.loadingHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.loaderCore {
display: inline-flex;
align-items: center;
gap: 10px;
color: #ffffff;
font-weight: 800;
}
.loaderRing {
width: 34px;
height: 34px;
border: 3px solid rgba(255, 255, 255, 0.16);
border-top-color: var(--teal);
border-radius: 50%;
animation: spin 0.85s linear infinite;
}
.loaderHint {
color: #9fb3c8;
font-family: "IBM Plex Mono", monospace;
font-size: 13px;
}
.loadingSteps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.loadingStep {
min-height: 74px;
padding: 11px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.loadingStep span {
display: block;
color: #9fb3c8;
font-size: 13px;
font-weight: 800;
}
.loadingStep strong {
display: block;
margin-top: 5px;
color: #ffffff;
font-size: 14px;
}
.loadingRows {
display: grid;
gap: 9px;
}
.loadingRow {
height: 15px;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
}
.loadingRow::after {
content: "";
display: block;
width: 38%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(20, 184, 166, 0.9), transparent);
animation: rowPulse 1.35s ease-in-out infinite;
}
.loadingRow:nth-child(2) { width: 82%; }
.loadingRow:nth-child(3) { width: 64%; }
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes scanEvidence {
from { transform: translateY(-55%); }
to { transform: translateY(55%); }
}
@keyframes rowPulse {
from { transform: translateX(-110%); }
to { transform: translateX(280%); }
}
.steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.step {
padding: 16px;
background: rgba(13, 23, 38, 0.92);
border: 1px solid var(--line);
border-radius: 14px;
}
.step b {
display: grid;
place-items: center;
width: 30px;
height: 30px;
margin-bottom: 10px;
color: #04141f;
background: var(--teal);
border-radius: 50%;
}
.empty {
padding: 18px;
color: var(--muted);
background: var(--soft);
border: 1px dashed var(--line);
border-radius: 12px;
}
.toast {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 40;
width: min(380px, calc(100vw - 28px));
padding: 14px 15px;
color: #fff;
background: #991b1b;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 12px;
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.38);
transform: translateY(18px);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast strong {
display: block;
margin-bottom: 2px;
}
/* Small desktop / large tablet */
@media (max-width: 1180px) {
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.categoryGrid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
/* Tablet */
@media (max-width: 940px) {
.hero, .incidentTop, .incidentMeta, .detailTitle {
align-items: flex-start;
flex-direction: column;
}
.hero {
grid-template-columns: 1fr;
}
.hero .button {
width: 100%;
}
.twoColumn, .steps {
grid-template-columns: 1fr;
}
.categoryGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.graphStats, .loadingSteps, .alertBanner {
grid-template-columns: 1fr;
}
.incidentList {
max-height: none;
}
}
@media (prefers-reduced-motion: reduce) {
.answer.loading::before, .loaderRing, .loadingRow::after, .graphShell::after, .riskGraph .bar, .riskGraph .riskLine {
animation: none;
}
}
/* Mobile */
@media (max-width: 680px) {
.navbar {
padding: 14px;
gap: 12px;
}
.page {
padding: 14px;
}
.cards {
grid-template-columns: 1fr;
}
.categoryGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.toolbar, .detailGrid, .evidenceLine {
grid-template-columns: 1fr;
}
.genRow {
flex-direction: column;
gap: 8px;
}
.heroStats {
grid-template-columns: 1fr 1fr;
}
.logMonitor {
font-size: 12px;
}
.logMonitor .logRow {
white-space: normal;
}
}
/* Small mobile (320px class) */
@media (max-width: 400px) {
.heroStats, .categoryGrid {
grid-template-columns: 1fr;
}
.brand {
min-width: 0;
}
.card strong {
font-size: 28px;
}
}
/* Hamburger navigation */
@media (max-width: 940px) {
.navbar {
flex-wrap: wrap;
align-items: center;
row-gap: 0;
}
.navToggle {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
margin-left: auto;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
transition: background 0.2s ease, border-color 0.2s ease;
}
.navToggle:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(94, 234, 212, 0.4);
}
.navToggleBars {
position: relative;
display: block;
width: 20px;
height: 2px;
background: #e6eef8;
border-radius: 2px;
transition: background 0.2s ease;
}
.navToggleBars::before,
.navToggleBars::after {
content: "";
position: absolute;
left: 0;
width: 20px;
height: 2px;
background: #e6eef8;
border-radius: 2px;
transition: transform 0.2s ease;
}
.navToggleBars::before { top: -6px; }
.navToggleBars::after { top: 6px; }
.navbar.nav-open .navToggleBars { background: transparent; }
.navbar.nav-open .navToggleBars::before { transform: translateY(6px) rotate(45deg); }
.navbar.nav-open .navToggleBars::after { transform: translateY(-6px) rotate(-45deg); }
.navTabs, .navActions {
display: none;
flex-basis: 100%;
width: 100%;
}
.navbar.nav-open .navTabs {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
border-radius: 14px;
overflow: visible;
}
.navbar.nav-open .navTabs .navTab {
width: 100%;
min-height: 44px;
justify-content: flex-start;
text-align: left;
}
.navbar.nav-open .navActions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
}
</style>
</head>
<body>
<nav class="navbar">
<div class="brand">
<span class="logo" aria-hidden="true">
<svg viewBox="0 0 64 64">
<defs>
<linearGradient id="rgRing" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#5eead4" />
<stop offset="1" stop-color="#0d9488" />
</linearGradient>
<linearGradient id="rgSweep" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#5eead4" stop-opacity="0.55" />
<stop offset="1" stop-color="#5eead4" stop-opacity="0" />
</linearGradient>
</defs>
<circle cx="32" cy="32" r="21" fill="none" stroke="url(#rgRing)" stroke-width="3.2" />
<circle cx="32" cy="32" r="11.5" fill="none" stroke="#5eead4" stroke-width="1.6" opacity="0.38" />
<g class="logoSweep">
<path d="M32 32 L32 11 A21 21 0 0 1 50.2 21.5 Z" fill="url(#rgSweep)" />
</g>
<circle cx="32" cy="32" r="2.4" fill="#5eead4" />
<circle class="logoBlip" cx="43.5" cy="22.5" r="3.1" fill="#7dd3fc" />
</svg>
</span>
<div><strong>ROGLAG</strong><span>Threat Intelligence</span></div>
</div>
<button class="navToggle" id="navToggle" aria-label="เปิด/ปิดเมนู" aria-expanded="false" aria-controls="navTabs">
<span class="navToggleBars" aria-hidden="true"></span>
</button>
<div class="navTabs" id="navTabs" role="tablist" aria-label="Main navigation">
<button class="navTab active" data-view="overview" title="ภาพรวมความเสี่ยงทั้งระบบ">Overview</button>
<button class="navTab" data-view="incidents" title="คิวเหตุการณ์ที่ต้องจัดการ">Incidents</button>
<button class="navTab" data-view="evidence" title="หลักฐาน log ที่เกี่ยวข้อง">Evidence</button>
<button class="navTab" data-view="assistant" title="ถาม AI สรุปเหตุการณ์ + next action">Assistant</button>
<button class="navTab" data-view="generator" title="จำลอง/สร้าง log สำหรับทดสอบ">Generator</button>
</div>
<div class="navActions">
<button class="alertButton" id="alertButton" aria-label="ดู critical alert ทั้งหมด" title="เปิดคิว Critical"><span class="alertDot"></span><span id="alertCount">0 critical</span></button>
<span class="statusPill"><span class="statusDot"></span><span id="systemStatus">Loading</span></span>
<button class="button navButton" id="refreshBtn">รีเฟรช</button>
</div>
</nav>
<section class="landingHero" id="landingHero">
<canvas id="globeCanvas" aria-hidden="true"></canvas>
<div class="heroOverlay"></div>
<div class="landingContent">
<h1 class="landingTitle">ROGLAG<br />Threat Intelligence</h1>
<p class="landingCopy">ตรวจจับและจัดลำดับภัยคุกคามจาก security log แบบเรียลไทม์ ดึงหลักฐานด้วย RAG แล้วรับมือ incident ที่มีความเสี่ยงสูงสุดก่อน</p>
<div class="heroControls">
<a class="heroButton" href="#dashboardStart">เปิดแดชบอร์ด</a>
<button class="heroButton secondary" id="heroBriefBtn">สรุปภัยคุกคามเร่งด่วน</button>
</div>
<div class="heroStats">
<div class="heroStat"><span>Critical alerts</span><strong id="heroCritical">-</strong></div>
<div class="heroStat"><span>Indexed logs</span><strong id="heroLogs">-</strong></div>
<div class="heroStat"><span>Retrieval</span><strong id="heroRetrieval">RAG</strong></div>
</div>
</div>
<a class="scrollCue" href="#dashboardStart" aria-label="Scroll to dashboard"></a>
</section>
<main class="page">
<section class="view active" id="overviewView">
<div id="dashboardStart"></div>
<header class="hero compact">
<button class="button primary" id="briefBtn">สรุปสถานการณ์จากหลักฐาน</button>
</header>
<section class="alertBanner" id="alertBanner" hidden>
<div>
<strong id="alertTitle">มี critical alert ที่ต้องตรวจสอบทันที</strong>
<p id="alertMessage">เปิดคิว incident เพื่อตรวจสอบหลักฐานและขั้นตอนรับมือ</p>
</div>
<button class="button" id="alertOpenBtn">เปิดรายการเร่งด่วน</button>
</section>
<section class="cards">
<article class="card">
<span>ความเร่งด่วน</span>
<div class="heatBar" id="heatBar"><span></span><span></span><span></span><span></span></div>
<small id="heatText">กำลังโหลด...</small>
</article>
<article class="card"><span>ต้องดูตอนนี้</span><strong id="openCount">-</strong><small>incident ที่ยังไม่ปิด</small></article>
<article class="card"><span>ยังไม่มีเจ้าของ</span><strong id="unassignedCount">-</strong><small>ควร assign ก่อน</small></article>
<article class="card"><span>Raw logs</span><strong id="rawCount">-</strong><small id="rawHint">dataset</small></article>
</section>
<section class="panel graphPanel">
<div class="panelHead">
<div><h2>Threat activity</h2><p>แนวโน้ม event ตามเวลา แยกสีตาม severity และเส้น risk pressure</p></div>
<div class="graphLegend">
<span class="legendItem"><i class="legendSwatch" style="background: var(--critical)"></i>Critical</span>
<span class="legendItem"><i class="legendSwatch" style="background: var(--high)"></i>High</span>
<span class="legendItem"><i class="legendSwatch" style="background: var(--medium)"></i>Medium</span>
<span class="legendItem"><i class="legendSwatch" style="background: var(--low)"></i>Low</span>
</div>
</div>
<div class="graphShell">
<svg class="riskGraph" id="riskGraph" viewBox="0 0 1100 315" role="img" aria-label="Threat activity chart"></svg>
</div>
<div class="graphStats">
<div class="graphStat"><span>Peak hour</span><strong id="graphPeak">-</strong></div>
<div class="graphStat"><span>Critical events</span><strong id="graphCritical">-</strong></div>
<div class="graphStat"><span>Risk pressure</span><strong id="graphPressure">-</strong></div>
</div>
</section>
<section class="panel categorySection">
<div class="panelHead">
<div><h2>Categories</h2><p>ภาพรวมทุกประเภทภัยคุกคาม เลือกเพื่อกรอง incident</p></div>
<select id="categorySort">
<option value="risk">เรียงตามความเสี่ยง</option>
<option value="count">เรียงตามจำนวน</option>
<option value="name">เรียงตามชื่อ</option>
</select>
</div>
<div class="panelBody">
<div class="categoryGrid" id="categoryGrid"></div>
</div>
</section>
<section class="steps">
<article class="step"><b>1</b><h3>ตรวจสอบ Incidents</h3><p class="subtle">เริ่มจากระดับ critical และ high ที่ระบบจัดกลุ่ม log ให้อัตโนมัติ</p></article>
<article class="step"><b>2</b><h3>ตรวจหลักฐาน</h3><p class="subtle">เลือก incident เพื่อดู log ที่เกี่ยวข้องโดยตรง ไม่ต้องไล่อ่าน raw log ทั้งหมด</p></article>
<article class="step"><b>3</b><h3>ปรึกษา Assistant</h3><p class="subtle">สรุปเหตุการณ์และขั้นตอนรับมือจากหลักฐานที่ระบบดึงมาได้</p></article>
</section>
</section>
<section class="view" id="incidentsView">
<section class="twoColumn">
<section class="panel">
<div class="panelHead">
<div><h2>Incident queue</h2><p id="queueMeta">กำลังโหลด...</p></div>
<button class="button" id="clearBtn">ล้างตัวกรอง</button>
</div>
<div class="toolbar">
<input id="searchInput" placeholder="ค้นหา IP, user, category, MITRE" />
<select id="severityFilter">
<option value="">ทุกระดับ</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="info">Low</option>
</select>
<select id="categoryFilter">
<option value="">ทุก category</option>
</select>
</div>
<div class="incidentList" id="incidentList"></div>
</section>
<section class="panel">
<div class="panelHead"><div><h2>Incident detail</h2><p>เลือก incident เพื่อดูรายละเอียดและเปลี่ยนสถานะ</p></div></div>
<div class="panelBody" id="incidentDetail"><div class="empty">เลือก incident จากรายการทางซ้าย</div></div>
</section>
</section>
</section>
<section class="view" id="evidenceView">
<section class="panel">
<div class="panelHead"><div><h2>Evidence</h2><p>หลักฐานตัวอย่างจาก incident ที่เลือกอยู่</p></div></div>
<div class="panelBody" id="evidencePanel"><div class="empty">เลือก incident ในหน้า Incidents ก่อน</div></div>
</section>
</section>
<section class="view" id="assistantView">
<section class="panel">
<div class="panelHead"><div><h2>Assistant</h2><p>ถามคำถามจากหลักฐานที่ระบบดึงมา โดยไม่ต้องตั้งค่าทางเทคนิค</p></div></div>
<div class="panelBody assistantBox">
<textarea id="questionInput" placeholder="เช่น สรุป incident นี้พร้อมแนะนำขั้นตอนรับมือ"></textarea>
<button class="button primary" id="askBtn">ถามจากหลักฐาน</button>
<div class="answer" id="answerBox">เริ่มด้วยปุ่ม “สรุปสถานการณ์จากหลักฐาน” หรือพิมพ์คำถามของคุณ</div>
</div>
</section>
</section>
<section class="view" id="generatorView">
<header class="genHead">
<h2>Log generator &amp; simulator</h2>
<p>จำลอง log เพื่อทดสอบ pipeline การตรวจจับ สตรีมแบบเรียลไทม์ หรืออัปโหลด CSV เพื่อประเมินความแม่นยำ</p>
</header>
<section class="cards">
<article class="card">
<span>Live log stream</span>
<div class="genRow">
<label>logs/วินาที<input type="number" id="genRate" value="6" min="1" max="50"></label>
<label>profile
<select id="genMockProfile"><option value="mixed">mixed</option><option value="attack">attack</option><option value="realistic">realistic</option></select>
</label>
</div>
<button class="button primary" id="genStreamBtn">เริ่มสตรีม</button>
<small class="subtle">เมื่อเริ่มสตรีม log จะไหลเข้า monitor ด้านล่าง และแดชบอร์ดอัปเดตสดทันที</small>
</article>
<article class="card">
<span>สร้างไฟล์ CSV</span>
<div class="genRow">
<label>จำนวนแถว<input type="number" id="genCsvCount" value="500" min="1" max="20000"></label>
<label>profile
<select id="genCsvProfile"><option value="mixed">mixed</option><option value="attack">attack</option><option value="realistic">realistic</option></select>
</label>
</div>
<button class="button" id="genDownloadBtn">ดาวน์โหลด CSV</button>
<small class="subtle">ข้อมูลสุ่มใหม่ทุกครั้ง นำไปอัปโหลดในขั้นถัดไปได้</small>
</article>
<article class="card">
<span>อัปโหลด CSV เพื่อตรวจจับ</span>
<input type="file" id="genCsvFile" accept=".csv" class="genFile">
<button class="button primary" id="genUploadBtn">อัปโหลดและตรวจจับ</button>
<small class="subtle">ระบบจัดประเภททุกแถวจาก message แล้วเทียบความแม่นยำกับ label เดิม (ถ้ามี)</small>
</article>
</section>
<section class="panel" style="margin-top: 16px;">
<div class="panelHead">
<div><h2>Realtime log monitor</h2><p>ติดตาม log ที่เข้าระบบแบบเรียลไทม์ พร้อมจัดประเภทอัตโนมัติ</p></div>
<div style="display:flex; align-items:center; gap:10px;">
<select id="logSort">
<option value="desc">เวลา ใหม่สุดก่อน</option>
<option value="asc">เวลา เก่าสุดก่อน</option>
</select>
<span class="statusPill"><span class="statusDot"></span><span id="genStatus">หยุด</span></span>
</div>
</div>
<div class="logMonitor" id="logMonitor"><div class="empty">กด ‘เริ่มสตรีม’ เพื่อดู log ที่เข้าระบบ</div></div>
</section>
</section>
</main>
<div class="toast" id="criticalToast" role="status" aria-live="polite">
<strong>Critical alert ใหม่</strong>
<span id="toastText">มีเหตุการณ์ critical เข้ามา</span>
</div>
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<script>
const CATEGORY_META = {
benign: { label: "Benign baseline", mitre: "-", risk: 10 },
brute_force: { label: "Brute force login", mitre: "T1110", risk: 78 },
port_scan: { label: "Port scan", mitre: "T1046", risk: 62 },
web_attack: { label: "Web exploit attempt", mitre: "T1190", risk: 82 },
priv_esc: { label: "Privilege escalation", mitre: "T1548", risk: 94 },
data_exfil: { label: "Data exfiltration", mitre: "T1041", risk: 96 },
c2_beacon: { label: "C2 beacon", mitre: "T1071", risk: 95 },
};
const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, info: 1 };
const CATEGORY_ORDER = ["data_exfil", "c2_beacon", "priv_esc", "web_attack", "brute_force", "port_scan", "benign"];
const STATES = ["New", "Investigating", "Resolved", "False-positive"];
const STATE_LABEL = { New: "ใหม่", Investigating: "กำลังตรวจ", Resolved: "ปิดแล้ว", "False-positive": "False positive" };
const OWNERS = ["ยังไม่ assign", "Mali", "Niran", "Ops-SOC", "Infra"];
const app = { health: null, stats: null, raw: [], incidents: [], alerts: [], selectedId: null, search: "", severity: "", category: "", categorySort: "risk" };
const $ = (id) => document.getElementById(id);
const fmt = (n) => new Intl.NumberFormat("th-TH").format(n ?? 0);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function escapeHtml(value) {
return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#039;");
}
function latLonToVector3(lat, lon, radius) {
const phi = (90 - lat) * Math.PI / 180;
const theta = (lon + 180) * Math.PI / 180;
return new THREE.Vector3(
-radius * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta)
);
}
function initGlobe() {
const canvas = $("globeCanvas");
if (!canvas || !window.THREE) return;
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(0, 0.2, 8.4);
const root = new THREE.Group();
root.position.x = window.innerWidth > 760 ? 1.65 : 0;
scene.add(root);
const globe = new THREE.Mesh(
new THREE.SphereGeometry(2.05, 64, 64),
new THREE.MeshBasicMaterial({ color: 0x0d2a42, transparent: true, opacity: 0.58 })
);
root.add(globe);
const wire = new THREE.Mesh(
new THREE.SphereGeometry(2.07, 32, 18),
new THREE.MeshBasicMaterial({ color: 0x5eead4, wireframe: true, transparent: true, opacity: 0.18 })
);
root.add(wire);
const halo = new THREE.Mesh(
new THREE.SphereGeometry(2.19, 64, 64),
new THREE.MeshBasicMaterial({ color: 0x14b8a6, transparent: true, opacity: 0.08, side: THREE.BackSide })
);
root.add(halo);
const outerHalo = new THREE.Mesh(
new THREE.SphereGeometry(2.55, 64, 64),
new THREE.MeshBasicMaterial({ color: 0x2dd4bf, transparent: true, opacity: 0.045, side: THREE.BackSide })
);
root.add(outerHalo);
const glow = new THREE.Mesh(
new THREE.SphereGeometry(3.05, 48, 48),
new THREE.MeshBasicMaterial({ color: 0x0ea5b7, transparent: true, opacity: 0.025, side: THREE.BackSide })
);
root.add(glow);
const cities = [
[13.75, 100.5], [35.68, 139.69], [1.35, 103.82], [51.5, -0.12],
[37.77, -122.42], [40.71, -74.0], [52.52, 13.4], [-33.86, 151.2],
[19.43, -99.13], [25.2, 55.27], [28.61, 77.2], [-23.55, -46.63],
];
const pointGeo = new THREE.BufferGeometry().setFromPoints(cities.map(([lat, lon]) => latLonToVector3(lat, lon, 2.12)));
const points = new THREE.Points(pointGeo, new THREE.PointsMaterial({
color: 0xccfbf1,
size: 0.07,
transparent: true,
opacity: 0.95,
}));
root.add(points);
function makeArc(from, to, color, opacity) {
const a = latLonToVector3(from[0], from[1], 2.16);
const b = latLonToVector3(to[0], to[1], 2.16);
const mid = a.clone().add(b).multiplyScalar(0.5).normalize().multiplyScalar(3.05);
const curve = new THREE.QuadraticBezierCurve3(a, mid, b);
const geometry = new THREE.TubeGeometry(curve, 40, 0.009, 6, false);
const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ color, transparent: true, opacity }));
root.add(mesh);
return curve;
}
const curves = [
makeArc(cities[0], cities[4], 0xe11d48, 0.68),
makeArc(cities[1], cities[6], 0x14b8a6, 0.58),
makeArc(cities[2], cities[8], 0xf97316, 0.54),
makeArc(cities[5], cities[9], 0xeab308, 0.5),
makeArc(cities[10], cities[3], 0x5eead4, 0.58),
makeArc(cities[7], cities[11], 0xe11d48, 0.48),
makeArc(cities[3], cities[1], 0x2dd4bf, 0.5),
makeArc(cities[4], cities[7], 0xf97316, 0.46),
makeArc(cities[8], cities[2], 0xeab308, 0.44),
];
const particleGeo = new THREE.SphereGeometry(0.035, 12, 12);
const particles = curves.map((curve, index) => {
const mesh = new THREE.Mesh(particleGeo, new THREE.MeshBasicMaterial({
color: index % 3 === 0 ? 0xe11d48 : index % 3 === 1 ? 0x5eead4 : 0xf97316,
}));
mesh.userData = { curve, offset: index / curves.length, speed: 0.0018 + index * 0.00018 };
root.add(mesh);
return mesh;
});
const starPositions = [];
for (let i = 0; i < 520; i += 1) {
starPositions.push((Math.random() - 0.5) * 16, (Math.random() - 0.5) * 9, -2 - Math.random() * 8);
}
const stars = new THREE.Points(
new THREE.BufferGeometry().setAttribute("position", new THREE.Float32BufferAttribute(starPositions, 3)),
new THREE.PointsMaterial({ color: 0x8fb8d8, size: 0.012, transparent: true, opacity: 0.55 })
);
scene.add(stars);
function resize() {
const { clientWidth, clientHeight } = canvas;
renderer.setSize(clientWidth, clientHeight, false);
camera.aspect = Math.max(clientWidth / Math.max(clientHeight, 1), 0.1);
camera.updateProjectionMatrix();
root.position.x = clientWidth > 760 ? 1.65 : 0;
root.scale.setScalar(clientWidth > 760 ? 1 : 0.82);
}
let frame = 0;
function animate() {
frame += 1;
root.rotation.y += 0.0026;
wire.rotation.y -= 0.0012;
halo.rotation.y += 0.001;
stars.rotation.y += 0.00025;
particles.forEach((particle) => {
const t = (particle.userData.offset + frame * particle.userData.speed) % 1;
particle.position.copy(particle.userData.curve.getPoint(t));
});
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
resize();
window.addEventListener("resize", resize);
animate();
}
async function api(path, options = {}) {
const response = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options });
const data = await response.json();
if (!response.ok) throw new Error(data.error || "request failed");
return data;
}
async function fetchLogs() {
const categories = ["c2_beacon", "data_exfil", "priv_esc", "brute_force", "web_attack", "port_scan", "benign"];
const pages = await Promise.all(categories.map((category) => api(`/api/logs?limit=500&category=${category}`)));
return pages.flatMap((page) => page.rows || []);
}
async function fetchStats() {
return api("/api/stats");
}
function incidentKey(row) {
if (row.category === "brute_force") return `${row.category}|${row.src_ip}|${row.user || "-"}`;
if (row.category === "port_scan") return `${row.category}|${row.src_ip}`;
if (row.category === "web_attack") return `${row.category}|${row.src_ip}|${row.attack_kind || "-"}`;
if (row.category === "priv_esc") return `${row.category}|${row.user || "-"}|${row.host || "-"}`;
if (row.category === "data_exfil") return `${row.category}|${row.src_ip}|${row.dst_ip}`;
if (row.category === "c2_beacon") return `${row.category}|${row.src_ip}|${row.dst_ip}|${row.beacon_interval || "-"}`;
return `${row.category}|${row.source}|${row.src_ip || row.path || "-"}`;
}
function groupIncidents(rows) {
const groups = new Map();
rows.forEach((row) => {
const key = incidentKey(row);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(row);
});
return [...groups.entries()].map(([key, items], index) => {
items.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
const first = items[0];
const last = items[items.length - 1];
const severity = items.reduce((current, row) => SEVERITY_RANK[row.severity] > SEVERITY_RANK[current] ? row.severity : current, first.severity || "info");
const meta = CATEGORY_META[first.category] || { label: first.category, mitre: "-" };
const stored = JSON.parse(localStorage.getItem(`sentinel:${key}`) || "{}");
return {
id: `INC-${String(index + 1).padStart(4, "0")}`,
key, category: first.category, title: meta.label, mitre: meta.mitre, severity, count: items.length,
firstSeen: first.timestamp, lastSeen: last.timestamp,
src: [...new Set(items.map((r) => r.src_ip).filter(Boolean))].slice(0, 2).join(", ") || "-",
dst: [...new Set(items.map((r) => r.dst_ip).filter(Boolean))].slice(0, 2).join(", ") || "-",
user: [...new Set(items.map((r) => r.user).filter(Boolean))].slice(0, 2).join(", ") || "-",
state: stored.state || "New",
owner: stored.owner || OWNERS[index % OWNERS.length],
rows: items,
};
}).sort((a, b) => (SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]) || b.count - a.count);
}
function selectedIncident() {
return app.incidents.find((incident) => incident.id === app.selectedId) || app.incidents[0];
}
function incidentForPrompt(prompt) {
const match = prompt.match(/\bINC-\d{4}\b/i);
if (match) {
const found = app.incidents.find((incident) => incident.id.toLowerCase() === match[0].toLowerCase());
if (found) return found;
}
return selectedIncident();
}
function filteredIncidents() {
const q = app.search.toLowerCase();
return app.incidents.filter((incident) => {
if (app.severity && incident.severity !== app.severity) return false;
if (app.category && incident.category !== app.category) return false;
if (!q) return true;
return [incident.id, incident.title, incident.category, incident.mitre, incident.src, incident.dst, incident.user, incident.rows[0]?.message].join(" ").toLowerCase().includes(q);
});
}
function categoryStats() {
return CATEGORY_ORDER.map((category) => {
const incidents = app.incidents.filter((incident) => incident.category === category);
const events = incidents.reduce((sum, incident) => sum + incident.count, 0);
const topSeverity = incidents.reduce((current, incident) => (
SEVERITY_RANK[incident.severity] > SEVERITY_RANK[current] ? incident.severity : current
), "info");
const meta = CATEGORY_META[category];
return { category, label: meta.label, mitre: meta.mitre, risk: meta.risk, incidents: incidents.length, events, topSeverity };
});
}
function sortedCategoryStats() {
const stats = categoryStats();
if (app.categorySort === "count") return stats.sort((a, b) => b.incidents - a.incidents || b.events - a.events);
if (app.categorySort === "name") return stats.sort((a, b) => a.label.localeCompare(b.label));
return stats.sort((a, b) => b.risk - a.risk);
}
function countBy(items, key) {
return items.reduce((acc, item) => {
acc[item[key]] = (acc[item[key]] || 0) + 1;
return acc;
}, {});
}
function timelineBuckets() {
const source = app.stats?.timeline_by_severity || {};
const buckets = Object.entries(source).map(([time, counts]) => ({
time,
critical: counts.critical || 0,
high: counts.high || 0,
medium: counts.medium || 0,
info: counts.info || 0,
}));
return buckets.slice(-48);
}
function linePath(points) {
if (!points.length) return "";
return points.map((point, index) => `${index ? "L" : "M"}${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" ");
}
function areaPath(points, baseY) {
if (!points.length) return "";
return `${linePath(points)} L${points[points.length - 1].x.toFixed(1)},${baseY} L${points[0].x.toFixed(1)},${baseY} Z`;
}
function renderRiskGraph() {
const svg = $("riskGraph");
const buckets = timelineBuckets();
if (!buckets.length) {
svg.innerHTML = `<text x="40" y="150" class="axisLabel">No timeline data</text>`;
return;
}
const width = 1100;
const height = 315;
const pad = { left: 42, right: 28, top: 28, bottom: 42 };
const graphW = width - pad.left - pad.right;
const graphH = height - pad.top - pad.bottom;
const totals = buckets.map((bucket) => bucket.critical + bucket.high + bucket.medium + bucket.info);
const risks = buckets.map((bucket) => bucket.critical * 4 + bucket.high * 2.4 + bucket.medium * 1.35 + bucket.info * 0.25);
const maxTotal = Math.max(...totals, 1);
const maxRisk = Math.max(...risks, 1);
const barGap = 4;
const barW = Math.max(5, graphW / buckets.length - barGap);
const colors = { critical: "#e11d48", high: "#f97316", medium: "#ca8a04", info: "#64748b" };
const severities = ["info", "medium", "high", "critical"];
const grid = [0.25, 0.5, 0.75, 1].map((tick) => {
const y = pad.top + graphH - graphH * tick;
return `<line class="gridLine" x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" />`;
}).join("");
let bars = "";
buckets.forEach((bucket, index) => {
const x = pad.left + index * (graphW / buckets.length) + barGap / 2;
let yCursor = pad.top + graphH;
severities.forEach((severity) => {
const value = bucket[severity];
if (!value) return;
const h = Math.max((value / maxTotal) * graphH, 1.5);
yCursor -= h;
bars += `<rect class="bar ${severity}Bar" style="animation-delay:${Math.min(index * 18, 520)}ms" x="${x.toFixed(1)}" y="${yCursor.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}"><title>${bucket.time} · ${severity}: ${value}</title></rect>`;
});
});
const riskPoints = risks.map((risk, index) => ({
x: pad.left + index * (graphW / Math.max(buckets.length - 1, 1)),
y: pad.top + graphH - (risk / maxRisk) * graphH,
}));
const peakIndex = risks.indexOf(maxRisk);
const peak = riskPoints[peakIndex];
const firstTime = buckets[0].time.slice(5, 13).replace("T", " ");
const lastTime = buckets[buckets.length - 1].time.slice(5, 13).replace("T", " ");
svg.innerHTML = `
<defs>
<linearGradient id="riskAreaGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#5eead4" stop-opacity="0.34" />
<stop offset="78%" stop-color="#5eead4" stop-opacity="0.03" />
</linearGradient>
<filter id="softGlow"><feGaussianBlur stdDeviation="4" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
</defs>
${grid}
${bars}
<path class="riskArea" d="${areaPath(riskPoints, pad.top + graphH)}" />
<path class="riskLine" d="${linePath(riskPoints)}" />
<circle class="peakDot" cx="${peak.x.toFixed(1)}" cy="${peak.y.toFixed(1)}" r="7" />
<text class="peakLabel" x="${Math.min(peak.x + 12, width - 185).toFixed(1)}" y="${Math.max(peak.y - 12, 18).toFixed(1)}">peak ${fmt(Math.round(maxRisk))}</text>
<text class="axisLabel" x="${pad.left}" y="${height - 14}">${escapeHtml(firstTime)}</text>
<text class="axisLabel" text-anchor="end" x="${width - pad.right}" y="${height - 14}">${escapeHtml(lastTime)}</text>
`;
const peakBucket = buckets[peakIndex];
const criticalTotal = buckets.reduce((sum, bucket) => sum + bucket.critical, 0);
const pressure = Math.round(risks.reduce((sum, value) => sum + value, 0) / buckets.length);
$("graphPeak").textContent = peakBucket.time.slice(5, 16).replace("T", " ");
$("graphCritical").textContent = fmt(criticalTotal);
$("graphPressure").textContent = fmt(pressure);
}
function renderSummary() {
const counts = countBy(app.incidents, "severity");
const total = Math.max(app.incidents.length, 1);
["critical", "high", "medium", "info"].forEach((sev, index) => {
const value = counts[sev] || 0;
$("heatBar").children[index].style.flexBasis = value ? `${(value / total) * 100}%` : "0";
});
$("heatText").textContent = `critical ${fmt(counts.critical || 0)} · high ${fmt(counts.high || 0)} · medium ${fmt(counts.medium || 0)} · low ${fmt(counts.info || 0)}`;
$("openCount").textContent = fmt(app.incidents.filter((i) => !["Resolved", "False-positive"].includes(i.state)).length);
$("unassignedCount").textContent = fmt(app.incidents.filter((i) => i.owner === "ยังไม่ assign" && i.state === "New").length);
$("rawCount").textContent = fmt(app.stats?.total ?? app.health.records);
$("rawHint").textContent = `${fmt(app.raw.length)} logs ใช้จัดกลุ่ม`;
$("systemStatus").textContent = app.health.vector_ready ? "พร้อม" : "พร้อมบางส่วน";
$("heroLogs").textContent = fmt(app.stats?.total ?? app.health.records);
$("heroRetrieval").textContent = app.health.vector_ready ? "Vector" : "Keyword";
}
function renderCategoryControls() {
$("categoryGrid").innerHTML = sortedCategoryStats().map((item) => `
<button class="categoryCard" data-category-pick="${item.category}" data-top-severity="${item.topSeverity}">
<strong>${escapeHtml(item.label)}</strong>
<b>${fmt(item.incidents)}</b>
<small>${item.mitre} · ${fmt(item.events)} events · risk ${item.risk}</small>
</button>
`).join("");
$("categoryFilter").innerHTML = `<option value="">ทุก category</option>` + CATEGORY_ORDER.map((category) => {
const meta = CATEGORY_META[category];
return `<option value="${category}">${escapeHtml(meta.label)}</option>`;
}).join("");
$("categoryFilter").value = app.category;
$("categorySort").value = app.categorySort;
document.querySelectorAll("[data-category-pick]").forEach((button) => {
button.addEventListener("click", () => {
app.category = button.dataset.categoryPick;
app.severity = "";
$("categoryFilter").value = app.category;
$("severityFilter").value = "";
showView("incidents");
renderIncidents();
});
});
}
function renderAlerts() {
const count = app.alerts.length;
$("alertCount").textContent = count ? `${fmt(count)} critical` : "0 critical";
$("alertButton").classList.toggle("active", count > 0);
$("heroCritical").textContent = fmt(count);
$("alertBanner").hidden = count === 0;
if (!count) return;
const top = app.alerts[0];
$("alertTitle").textContent = `critical alert ${fmt(count)} รายการรอการตรวจสอบ`;
$("alertMessage").textContent = `${top.title} · ${top.src_ip || "-"}${top.dst_ip || "-"} · ${fmt(top.count)} events · ${top.last_seen}`;
}
function renderIncidents() {
const incidents = filteredIncidents();
const filters = [app.severity || "", app.category ? CATEGORY_META[app.category]?.label : ""].filter(Boolean).join(" · ");
$("queueMeta").textContent = `${fmt(incidents.length)} incidents${filters ? ` · ${filters}` : ""} · เลือกเพื่อดูรายละเอียด`;
$("incidentList").innerHTML = incidents.length ? incidents.map((incident) => `
<button class="incidentButton ${incident.id === app.selectedId ? "selected" : ""}" data-id="${incident.id}" data-severity="${incident.severity}">
<div class="incidentTop">
<div class="incidentName">
<strong>${incident.id} · ${escapeHtml(incident.title)}</strong>
<small>${incident.mitre} · ${escapeHtml(incident.src)}${escapeHtml(incident.dst)}</small>
</div>
<span class="severity ${incident.severity}">${incident.severity}</span>
</div>
<div class="incidentMeta">
<span>${STATE_LABEL[incident.state]} · ${escapeHtml(incident.owner)}</span>
<span>${fmt(incident.count)} events</span>
</div>
</button>
`).join("") : `<div class="empty">ไม่พบ incident ตามตัวกรองนี้</div>`;
document.querySelectorAll("[data-id]").forEach((button) => {
button.addEventListener("click", () => {
app.selectedId = button.dataset.id;
renderAll();
showView("incidents");
});
});
}
function evidenceMarkup(incident, limit = 6) {
return incident.rows.slice(0, limit).map((row, index) => `
<div class="evidenceLine">
<span>[#${index + 1}] ${escapeHtml(row.timestamp)}</span>
<code>${escapeHtml(row.message)}</code>
</div>
`).join("");
}
function renderDetail() {
const incident = selectedIncident();
if (!incident) {
$("incidentDetail").innerHTML = `<div class="empty">ยังไม่มี incident</div>`;
$("evidencePanel").innerHTML = `<div class="empty">ยังไม่มีหลักฐาน</div>`;
return;
}
app.selectedId = incident.id;
$("incidentDetail").innerHTML = `
<div class="detailTitle">
<div><h2>${incident.id} · ${escapeHtml(incident.title)}</h2><p class="subtle">${incident.mitre} · ${incident.severity} · ${fmt(incident.count)} events</p></div>
<span class="severity ${incident.severity}">${incident.severity}</span>
</div>
<div class="detailGrid">
<div class="metaBox"><span>source</span><strong>${escapeHtml(incident.src)}</strong></div>
<div class="metaBox"><span>destination</span><strong>${escapeHtml(incident.dst)}</strong></div>
<div class="metaBox"><span>user</span><strong>${escapeHtml(incident.user)}</strong></div>
</div>
<div class="stateRow">
${STATES.map((state) => `<button class="stateButton ${incident.state === state ? "active" : ""}" data-state="${state}">${STATE_LABEL[state]}</button>`).join("")}
</div>
<p class="subtle">เปลี่ยนสถานะได้จากปุ่มด้านบน ข้อมูลนี้เก็บใน browser local storage สำหรับ demo</p>
`;
$("evidencePanel").innerHTML = `<h2>${incident.id} · Evidence</h2><p class="subtle">ตัวอย่าง log ที่เกี่ยวข้อง</p><div class="evidenceList">${evidenceMarkup(incident, 10)}</div>`;
document.querySelectorAll("[data-state]").forEach((button) => {
button.addEventListener("click", () => {
incident.state = button.dataset.state;
localStorage.setItem(`sentinel:${incident.key}`, JSON.stringify({ state: incident.state, owner: incident.owner }));
renderAll();
});
});
$("questionInput").placeholder = `ถามต่อเกี่ยวกับ ${incident.id} เช่น ควร isolate เครื่องไหนก่อน`;
}
function renderAll() {
renderSummary();
renderRiskGraph();
renderCategoryControls();
renderAlerts();
renderIncidents();
renderDetail();
}
function showView(name) {
document.querySelectorAll(".view").forEach((view) => view.classList.remove("active"));
document.querySelectorAll(".navTab").forEach((tab) => tab.classList.toggle("active", tab.dataset.view === name));
$(`${name}View`).classList.add("active");
// Globe hero lives only on Overview.
const hero = $("landingHero");
if (hero) {
const onOverview = name === "overview";
hero.style.display = onOverview ? "" : "none";
if (onOverview) {
window.scrollTo({ top: 0 });
// canvas was hidden -> let the WebGL globe recompute its size
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
}
}
}
function renderMarkdown(markdown) {
const lines = escapeHtml(markdown || "").split(/\r?\n/);
const html = [];
let list = null;
const closeList = () => {
if (list) html.push(`</${list}>`);
list = null;
};
const inline = (value) => value
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
lines.forEach((line) => {
if (!line.trim()) {
closeList();
return;
}
if (line.startsWith("### ")) {
closeList();
html.push(`<h3>${inline(line.slice(4))}</h3>`);
return;
}
if (line.startsWith("## ")) {
closeList();
html.push(`<h2>${inline(line.slice(3))}</h2>`);
return;
}
if (line.startsWith("# ")) {
closeList();
html.push(`<h2>${inline(line.slice(2))}</h2>`);
return;
}
if (/^- /.test(line)) {
if (list !== "ul") {
closeList();
list = "ul";
html.push("<ul>");
}
html.push(`<li>${inline(line.slice(2))}</li>`);
return;
}
if (/^\d+\. /.test(line)) {
if (list !== "ol") {
closeList();
list = "ol";
html.push("<ol>");
}
html.push(`<li>${inline(line.replace(/^\d+\. /, ""))}</li>`);
return;
}
closeList();
html.push(`<p>${inline(line)}</p>`);
});
closeList();
return html.join("");
}
function renderAnswer(data) {
const mode = data.mode === "open" + "router" ? "AI summary" : "local summary";
const cleanedAnswer = String(data.answer || "").replace(/^User Safety:\s*safe\s*/i, "").trim();
const citations = (data.hits || []).slice(0, 4).map((hit, idx) => `
<div class="citationItem">
<strong>[#${idx + 1}] ${escapeHtml(hit.row.timestamp)} · ${escapeHtml(hit.row.category)}/${escapeHtml(hit.row.severity)}</strong>
${escapeHtml(hit.row.message)}
</div>
`).join("");
$("answerBox").innerHTML = `
<div class="answerMeta">${mode} · evidence=${escapeHtml(data.retrieval || "none")}</div>
${renderMarkdown(cleanedAnswer)}
<section class="citationBlock">
<h2>หลักฐานอ้างอิง</h2>
${citations || "<p>-</p>"}
</section>
`;
}
function setAssistantLoading(isLoading) {
$("answerBox").classList.toggle("loading", isLoading);
$("askBtn").disabled = isLoading;
$("askBtn").textContent = isLoading ? "กำลังวิเคราะห์..." : "ถามจากหลักฐาน";
if (!isLoading) return;
$("answerBox").innerHTML = `
<div class="loadingDeck">
<div class="loadingHead">
<div class="loaderCore"><span class="loaderRing"></span><span>กำลังวิเคราะห์หลักฐาน</span></div>
<span class="loaderHint">retrieval · rules · summary</span>
</div>
<div class="loadingSteps">
<div class="loadingStep"><span>STEP 01</span><strong>ค้น log ที่เกี่ยวข้อง</strong></div>
<div class="loadingStep"><span>STEP 02</span><strong>เทียบ rule และ MITRE</strong></div>
<div class="loadingStep"><span>STEP 03</span><strong>สรุป next action</strong></div>
</div>
<div class="loadingRows" aria-hidden="true">
<div class="loadingRow"></div>
<div class="loadingRow"></div>
<div class="loadingRow"></div>
</div>
</div>
`;
}
async function ask(question) {
const typed = $("questionInput").value.trim();
const incident = incidentForPrompt(question || typed);
const prompt = question || $("questionInput").value.trim() || `สรุป ${incident?.id || "incident"} และแนะนำ next action พร้อมหลักฐาน`;
$("questionInput").value = prompt;
setAssistantLoading(true);
showView("assistant");
try {
const [data] = await Promise.all([
api("/api/ask", {
method: "POST",
body: JSON.stringify({
question: prompt,
use_llm: true,
retrieval: "auto",
limit: 8,
incident_id: incident?.id,
context_rows: incident?.rows?.slice(0, 12) || [],
}),
}),
wait(850),
]);
setAssistantLoading(false);
renderAnswer(data);
} catch (error) {
setAssistantLoading(false);
$("answerBox").innerHTML = renderMarkdown(`## สรุปไม่สำเร็จ\n\n- ${error.message}\n- ลองกดถามอีกครั้ง หรือเปลี่ยนคำถามให้เจาะจง category/IP มากขึ้น`);
}
}
function openCriticalQueue() {
app.severity = "critical";
app.category = "";
$("severityFilter").value = "critical";
$("categoryFilter").value = "";
showView("incidents");
renderIncidents();
}
function showCriticalToast(alert) {
$("toastText").textContent = `${alert.title} · ${alert.src_ip || "-"}${alert.dst_ip || "-"} · ${alert.last_seen}`;
$("criticalToast").classList.add("show");
clearTimeout(showCriticalToast.timer);
showCriticalToast.timer = setTimeout(() => $("criticalToast").classList.remove("show"), 5200);
}
async function loadAlerts({ notify = false } = {}) {
const data = await api("/api/alerts?limit=20");
const newest = data.alerts?.[0]?.id || "";
const previous = localStorage.getItem("sentinel:lastCriticalAlert") || "";
app.alerts = data.alerts || [];
renderAlerts();
if (notify && previous && newest && newest !== previous) showCriticalToast(app.alerts[0]);
if (newest) localStorage.setItem("sentinel:lastCriticalAlert", newest);
}
function bindEvents() {
const navbar = document.querySelector(".navbar");
const navToggle = $("navToggle");
const closeNav = () => {
navbar.classList.remove("nav-open");
if (navToggle) navToggle.setAttribute("aria-expanded", "false");
};
if (navToggle) {
navToggle.addEventListener("click", () => {
const open = navbar.classList.toggle("nav-open");
navToggle.setAttribute("aria-expanded", open ? "true" : "false");
});
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeNav();
});
document.addEventListener("click", (event) => {
if (navbar.classList.contains("nav-open") && !navbar.contains(event.target)) closeNav();
});
document.querySelectorAll(".navTab").forEach((tab) => tab.addEventListener("click", () => { showView(tab.dataset.view); closeNav(); }));
$("refreshBtn").addEventListener("click", init);
$("alertButton").addEventListener("click", openCriticalQueue);
$("alertOpenBtn").addEventListener("click", openCriticalQueue);
$("heroBriefBtn").addEventListener("click", () => ask("สรุป critical alerts ตอนนี้ พร้อมหลักฐานและ next action"));
$("briefBtn").addEventListener("click", () => ask("สรุป incident ที่ควรดูด่วนที่สุดตอนนี้ พร้อมเหตุผล หลักฐาน และ next action"));
$("askBtn").addEventListener("click", () => ask());
$("searchInput").addEventListener("input", debounce((event) => {
app.search = event.target.value.trim();
renderIncidents();
}, 160));
$("severityFilter").addEventListener("change", (event) => {
app.severity = event.target.value;
renderIncidents();
});
$("categoryFilter").addEventListener("change", (event) => {
app.category = event.target.value;
renderIncidents();
});
$("categorySort").addEventListener("change", (event) => {
app.categorySort = event.target.value;
renderCategoryControls();
});
$("clearBtn").addEventListener("click", () => {
app.search = "";
app.severity = "";
app.category = "";
$("searchInput").value = "";
$("severityFilter").value = "";
$("categoryFilter").value = "";
renderIncidents();
});
}
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}
async function init() {
try {
app.health = await api("/api/health");
await loadAlerts();
app.stats = await fetchStats();
app.raw = await fetchLogs();
app.incidents = groupIncidents(app.raw);
app.selectedId = app.incidents[0]?.id || null;
renderAll();
} catch (error) {
$("systemStatus").textContent = "ผิดพลาด";
$("incidentList").innerHTML = `<div class="empty">โหลดข้อมูลไม่สำเร็จ: ${escapeHtml(error.message)}</div>`;
}
}
// ---- Log generator / realtime monitor ----
const genState = { live: false, timer: null, ticks: 0, logs: [], sort: "desc" };
const SEV_LABEL = { critical: "CRIT", high: "HIGH", medium: "MED", info: "INFO" };
async function refreshData() {
app.stats = await fetchStats();
app.raw = await fetchLogs();
app.incidents = groupIncidents(app.raw);
if (!app.incidents.find((i) => i.id === app.selectedId)) app.selectedId = app.incidents[0]?.id || null;
await loadAlerts();
renderAll();
}
function renderMonitor() {
const box = $("logMonitor");
if (!genState.logs.length) { box.innerHTML = '<div class="empty">กด ‘เริ่มสตรีม’ เพื่อดู log ที่เข้าระบบ</div>'; return; }
const dir = genState.sort === "asc" ? 1 : -1;
const rows = [...genState.logs].sort((a, b) => dir * String(a.timestamp || "").localeCompare(String(b.timestamp || "")));
box.innerHTML = rows.map((ev) => {
const sev = ev.severity || "info";
const time = (ev.timestamp || "").replace("T", " ").slice(11, 19);
return `<div class="logRow"><span class="lt">${time}</span>` +
`<span class="lsev sev-${sev}">${SEV_LABEL[sev] || String(sev).toUpperCase()}</span>` +
`<span class="src">${escapeHtml(ev.source || "-")}</span>` +
`<span class="lmsg">${escapeHtml(ev.message || "")}</span></div>`;
}).join("");
if (genState.sort === "asc") box.scrollTop = box.scrollHeight; else box.scrollTop = 0;
}
function monitorAppend(events) {
genState.logs.push(...events);
if (genState.logs.length > 400) genState.logs = genState.logs.slice(-400);
renderMonitor();
}
async function genTick() {
const rate = $("genRate").value, profile = $("genMockProfile").value;
try {
const s = await api(`/api/mock/emit?count=${rate}&profile=${profile}`, { method: "POST" });
if (s.events) monitorAppend(s.events);
genState.ticks += 1;
if (genState.ticks % 3 === 0) await refreshData(); // throttle full dashboard refresh
} catch (e) { /* keep the stream alive on transient errors */ }
}
function genToggleStream() {
if (!genState.live) {
genState.live = true;
$("genStreamBtn").textContent = "หยุดสตรีม";
$("genStreamBtn").classList.add("danger");
$("genStatus").textContent = "LIVE";
genTick();
genState.timer = setInterval(genTick, 1000);
} else {
genState.live = false;
clearInterval(genState.timer);
$("genStreamBtn").textContent = "เริ่มสตรีม";
$("genStreamBtn").classList.remove("danger");
$("genStatus").textContent = "หยุด";
refreshData().catch(() => {});
}
}
function genDownload() {
const count = $("genCsvCount").value, profile = $("genCsvProfile").value;
window.location = `/api/generate/csv?count=${count}&profile=${profile}`;
}
async function genUpload() {
const file = $("genCsvFile").files[0];
if (!file) { alert("เลือกไฟล์ CSV ก่อน"); return; }
try {
const content = await file.text();
const s = await api("/api/ingest/csv", { method: "POST", body: JSON.stringify({ filename: file.name, content }) });
if (s.error) { alert(s.error); return; }
if (s.events) monitorAppend(s.events);
await refreshData();
} catch (e) { alert("อัปโหลดไม่สำเร็จ: " + e.message); }
}
function bindGenerator() {
$("genStreamBtn").addEventListener("click", genToggleStream);
$("logSort").addEventListener("change", (e) => { genState.sort = e.target.value; renderMonitor(); });
$("genDownloadBtn").addEventListener("click", genDownload);
$("genUploadBtn").addEventListener("click", genUpload);
}
bindEvents();
bindGenerator();
initGlobe();
init();
setInterval(() => loadAlerts({ notify: true }).catch(() => {}), 15000);
</script>
</body>
</html>