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-v2 / src / hooks / useFeeds.js 6654 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
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
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())
  // Ids of triage events an analyst has acknowledged. A fresh live pull mints new
  // ids, so acknowledgements naturally clear when new critical intel arrives.
  const [acknowledgedIds, setAcknowledgedIds] = useState(() => new Set())

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

  const applySnapshot = useCallback((snap) => {
    const iocs = Array.isArray(snap.iocs) ? snap.iocs : []
    setStore(iocs)
    setSourceStatus(snap.sources || {})
    setLastPull(snap.generatedAt || new Date().toISOString())
    setDisplayTick(Date.now())
    // Drop acknowledgements for ids that no longer exist (keeps the set bounded
    // and re-arms the alarm after a pull replaces the id space).
    setAcknowledgedIds((prev) => {
      if (prev.size === 0) return prev
      const live = new Set(iocs.map((r) => r.id))
      return new Set([...prev].filter((id) => live.has(id)))
    })
  }, [])

  // --- 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])

  // --- Critical-triage acknowledgement --------------------------------------
  const acknowledge = useCallback((id) => {
    setAcknowledgedIds((prev) => {
      const next = new Set(prev)
      next.add(id)
      return next
    })
  }, [])

  const acknowledgeAll = useCallback(() => {
    setAcknowledgedIds((prev) => {
      const next = new Set(prev)
      critical.forEach((c) => next.add(c.id))
      return next
    })
  }, [critical])

  // Number of critical/high triage rows still awaiting acknowledgement.
  const triageCount = useMemo(
    () => critical.filter((c) => !acknowledgedIds.has(c.id)).length,
    [critical, acknowledgedIds]
  )

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