Catalyst / admin/SAynapse-Horizon 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / SAynapse-Horizon

public

Self Hosted Cyber Threat Intelligence Hub

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
SAynapse-Horizon / Synapse-Horizon / src / hooks / useFeeds.js 5324 B · main
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SOURCES, REFRESH_OPTIONS, CADENCE_OPTIONS } from '../data/sources'
import { severityMeta } from '../utils/theme'
import { getFeeds, syncFeeds } from '../api/client'

const initialEnabled = () => Object.fromEntries(SOURCES.map((s) => [s.key, s.defaultOn]))

/**
 * Owns all CTI state. Data now comes from the live ingestion backend:
 *  - initial load  → GET /api/feeds (cached snapshot)
 *  - force sync / cadence tick → POST /api/sync (live pull)
 *  - toggling a source filters the local store instantly; enabling one with no
 *    data pulls just that source.
 */
export function useFeeds() {
  const [enabled, setEnabled] = useState(initialEnabled)
  const [store, setStore] = useState([]) // all normalized IOCs from the backend
  const [sourceStatus, setSourceStatus] = useState({}) // key -> { status, configured, message, ... }
  const [loading, setLoading] = useState(true)
  const [lastPull, setLastPull] = useState(null)
  const [error, setError] = useState(null)
  const [refreshKey, setRefreshKey] = useState('30s')
  const [cadenceKey, setCadenceKey] = useState('6h')
  const [displayTick, setDisplayTick] = useState(Date.now())

  const pullTimer = useRef(null)
  const refreshTimer = useRef(null)
  const inflight = useRef(false)

  const applySnapshot = useCallback((snap) => {
    setStore(Array.isArray(snap.iocs) ? snap.iocs : [])
    setSourceStatus(snap.sources || {})
    setLastPull(snap.generatedAt || new Date().toISOString())
    setDisplayTick(Date.now())
  }, [])

  // --- Live pull (ingestion lifecycle) --------------------------------------
  const pull = useCallback(
    async (sources) => {
      if (inflight.current) return
      inflight.current = true
      setLoading(true)
      setError(null)
      try {
        const snap = await syncFeeds(sources)
        applySnapshot(snap)
      } catch (err) {
        setError(err.message || 'Ingestion service unreachable')
      } finally {
        setLoading(false)
        inflight.current = false
      }
    },
    [applySnapshot]
  )

  // --- Initial load: read the cached snapshot, sync only if empty -----------
  useEffect(() => {
    let alive = true
    ;(async () => {
      try {
        const snap = await getFeeds()
        if (!alive) return
        applySnapshot(snap)
        if (!snap.iocs?.length) await pull()
      } catch (err) {
        if (alive) setError(err.message || 'Ingestion service unreachable')
      } finally {
        if (alive) setLoading(false)
      }
    })()
    return () => {
      alive = false
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  // --- Background feed-pull cadence -----------------------------------------
  useEffect(() => {
    if (pullTimer.current) clearInterval(pullTimer.current)
    const opt = CADENCE_OPTIONS.find((o) => o.key === cadenceKey)
    if (opt && opt.ms > 0) pullTimer.current = setInterval(() => pull(), opt.ms)
    return () => pullTimer.current && clearInterval(pullTimer.current)
  }, [cadenceKey, pull])

  // --- Display-layer auto-refresh -------------------------------------------
  // Re-reads the cached snapshot (cheap) and recomputes ages / re-sorts.
  useEffect(() => {
    if (refreshTimer.current) clearInterval(refreshTimer.current)
    const opt = REFRESH_OPTIONS.find((o) => o.key === refreshKey)
    if (opt && opt.ms > 0) {
      refreshTimer.current = setInterval(async () => {
        setDisplayTick(Date.now())
        try {
          const snap = await getFeeds()
          applySnapshot(snap)
        } catch {
          /* transient — keep last known state */
        }
      }, opt.ms)
    }
    return () => refreshTimer.current && clearInterval(refreshTimer.current)
  }, [refreshKey, applySnapshot])

  const toggleSource = useCallback(
    (key) => {
      setEnabled((prev) => {
        const next = { ...prev, [key]: !prev[key] }
        // Turning a source on with no cached data → pull just that source.
        if (next[key]) {
          const hasData = store.some((r) => r.source === key)
          const st = sourceStatus[key]
          if (!hasData && (!st || st.configured !== false)) pull([key])
        }
        return next
      })
    },
    [store, sourceStatus, pull]
  )

  // --- Derived, source-filtered views ---------------------------------------
  const visible = useMemo(() => store.filter((r) => enabled[r.source]), [store, enabled])

  const critical = useMemo(() => {
    return [...visible]
      .sort((a, b) => {
        const rank = severityMeta(b.severity).rank - severityMeta(a.severity).rank
        if (rank !== 0) return rank
        if (b.confidence !== a.confidence) return b.confidence - a.confidence
        return new Date(b.detectedAt) - new Date(a.detectedAt)
      })
      .filter((r) => r.severity === 'critical' || r.severity === 'high')
      .slice(0, 5)
  }, [visible])

  const activeCount = useMemo(() => SOURCES.filter((s) => enabled[s.key]).length, [enabled])

  return {
    enabled,
    loading,
    lastPull,
    error,
    refreshKey,
    cadenceKey,
    displayTick,
    sourceStatus,
    store,
    visible,
    critical,
    activeCount,
    totalSources: SOURCES.length,
    pull,
    toggleSource,
    setRefreshKey,
    setCadenceKey,
  }
}