| <!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; |
| } |
| |
| |
| @media (max-width: 1180px) { |
| .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } |
| .categoryGrid { grid-template-columns: repeat(4, minmax(0, 1fr)); } |
| } |
| |
| |
| @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; |
| } |
| } |
| |
| |
| @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; |
| } |
| } |
| |
| |
| @media (max-width: 400px) { |
| .heroStats, .categoryGrid { |
| grid-template-columns: 1fr; |
| } |
| |
| .brand { |
| min-width: 0; |
| } |
| |
| .card strong { |
| font-size: 28px; |
| } |
| } |
| |
| |
| @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 & 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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); |
| } |
| |
| 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"); |
| |
| |
| const hero = $("landingHero"); |
| if (hero) { |
| const onOverview = name === "overview"; |
| hero.style.display = onOverview ? "" : "none"; |
| if (onOverview) { |
| window.scrollTo({ top: 0 }); |
| |
| 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>`; |
| } |
| } |
| |
| |
| 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(); |
| } catch (e) { } |
| } |
| 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> |
|
|