fix(aria): IOC Feeds — restore app, fix API shapes, canonical UI
CD - Develop / build-and-deploy (push) Failing after 10m58s
CD - Develop / build-and-deploy (push) Failing after 10m58s
Root cause: IocFeeds.svelte called feeds.find() on a Map (backend returned
Map<String,Object> not List) → reactive statement crash → entire Svelte tree
broken, all sidebar nav dead.
Backend:
- /feeds/status now returns List<Map> with feedName/configured/enabled/
lastRunAt/lastRunStatus/lastIocsAdded/lastIocsUpdated fields
- /feeds/runs now returns full Page<IocFeedRun> (not stripped .getContent())
- IocFeedService.isConfigured(feedName) added for per-feed key check
- Feed order fixed to [OTX, MALWARE_BAZAAR, THREAT_FOX] (deterministic)
Frontend:
- loadFeeds: Array.isArray guard prevents crash if shape is ever wrong
- loadRuns: safe fallback for both Page and plain-array responses
- api.ts refreshFeed URL fixed: /feeds/refresh/{name} not /feeds/{name}/refresh
- IocFeedStatus type: added lastIocsUpdated
- IocFeeds.svelte: Refresh All moved from header to toolbar; all buttons
use canonical btn/btn-primary/btn-secondary/btn-sm classes; pollInterval
now calls pollStatus (status-only poll) not full refresh trigger
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@
|
|||||||
import { addToast } from '../lib/stores';
|
import { addToast } from '../lib/stores';
|
||||||
import Pagination from './common/Pagination.svelte';
|
import Pagination from './common/Pagination.svelte';
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────────────
|
|
||||||
let feeds: IocFeedStatus[] = [];
|
let feeds: IocFeedStatus[] = [];
|
||||||
let feedsLoading = true;
|
let feedsLoading = true;
|
||||||
|
|
||||||
@@ -18,26 +17,23 @@
|
|||||||
let refreshingAll = false;
|
let refreshingAll = false;
|
||||||
let refreshingFeed: Record<string, boolean> = {};
|
let refreshingFeed: Record<string, boolean> = {};
|
||||||
|
|
||||||
// ── Feed display config ───────────────────────────────────────────────
|
const FEED_META: Record<string, { label: string; icon: string }> = {
|
||||||
const FEED_META: Record<string, { label: string; icon: string; requiresKey: boolean }> = {
|
OTX: { label: 'AlienVault OTX', icon: '🔗' },
|
||||||
OTX: { label: 'AlienVault OTX', icon: '🔗', requiresKey: true },
|
MALWARE_BAZAAR: { label: 'MalwareBazaar', icon: '🦠' },
|
||||||
MALWARE_BAZAAR: { label: 'MalwareBazaar', icon: '🦠', requiresKey: false },
|
THREAT_FOX: { label: 'ThreatFox', icon: '🦊' },
|
||||||
THREAT_FOX: { label: 'ThreatFox', icon: '🦊', requiresKey: false },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure cards always render in a fixed order even if backend returns them in different order
|
|
||||||
const FEED_ORDER = ['OTX', 'MALWARE_BAZAAR', 'THREAT_FOX'];
|
const FEED_ORDER = ['OTX', 'MALWARE_BAZAAR', 'THREAT_FOX'];
|
||||||
|
|
||||||
$: orderedFeeds = FEED_ORDER.map(name => feeds.find(f => f.feedName === name) ?? {
|
$: orderedFeeds = FEED_ORDER.map(name =>
|
||||||
feedName: name, enabled: false, configured: false,
|
feeds.find(f => f.feedName === name) ?? { feedName: name, enabled: false, configured: false } as IocFeedStatus
|
||||||
} as IocFeedStatus);
|
);
|
||||||
|
|
||||||
// ── Data loading ──────────────────────────────────────────────────────
|
|
||||||
async function loadFeeds() {
|
async function loadFeeds() {
|
||||||
feedsLoading = true;
|
feedsLoading = true;
|
||||||
try {
|
try {
|
||||||
const res = await ariaApi.getFeedStatus();
|
const res = await ariaApi.getFeedStatus();
|
||||||
feeds = res.data;
|
feeds = Array.isArray(res.data) ? res.data : [];
|
||||||
} catch {
|
} catch {
|
||||||
addToast('error', 'Load Failed', 'Could not load feed status');
|
addToast('error', 'Load Failed', 'Could not load feed status');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -49,28 +45,31 @@
|
|||||||
runsLoading = true;
|
runsLoading = true;
|
||||||
try {
|
try {
|
||||||
const res = await ariaApi.getFeedRuns({ page, size: runsPageSize });
|
const res = await ariaApi.getFeedRuns({ page, size: runsPageSize });
|
||||||
runs = res.data.content;
|
const d = res.data as any;
|
||||||
runsTotalElements = res.data.page.totalElements;
|
runs = d.content ?? (Array.isArray(d) ? d : []);
|
||||||
runsTotalPages = res.data.page.totalPages;
|
runsTotalElements = d.page?.totalElements ?? runs.length;
|
||||||
runsPage = res.data.page.number;
|
runsTotalPages = d.page?.totalPages ?? 1;
|
||||||
|
runsPage = d.page?.number ?? page;
|
||||||
} catch {
|
} catch {
|
||||||
addToast('error', 'Load Failed', 'Could not load feed run history');
|
addToast('error', 'Load Failed', 'Could not load run history');
|
||||||
} finally {
|
} finally {
|
||||||
runsLoading = false;
|
runsLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function pollStatus() {
|
||||||
await Promise.all([loadFeeds(), loadRuns(runsPage)]);
|
try {
|
||||||
|
const res = await ariaApi.getFeedStatus();
|
||||||
|
feeds = Array.isArray(res.data) ? res.data : [];
|
||||||
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Refresh actions ───────────────────────────────────────────────────
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
refreshingAll = true;
|
refreshingAll = true;
|
||||||
try {
|
try {
|
||||||
const res = await ariaApi.refreshAllFeeds();
|
await ariaApi.refreshAllFeeds();
|
||||||
addToast('success', 'Refresh Triggered', res.data.message || 'All feeds are refreshing');
|
addToast('success', 'Refresh Triggered', 'All feeds are refreshing');
|
||||||
setTimeout(() => refresh(), 2000);
|
setTimeout(() => { loadFeeds(); loadRuns(runsPage); }, 3000);
|
||||||
} catch {
|
} catch {
|
||||||
addToast('error', 'Refresh Failed', 'Could not trigger feed refresh');
|
addToast('error', 'Refresh Failed', 'Could not trigger feed refresh');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -81,54 +80,48 @@
|
|||||||
async function refreshSingle(feedName: string) {
|
async function refreshSingle(feedName: string) {
|
||||||
refreshingFeed = { ...refreshingFeed, [feedName]: true };
|
refreshingFeed = { ...refreshingFeed, [feedName]: true };
|
||||||
try {
|
try {
|
||||||
const res = await ariaApi.refreshFeed(feedName);
|
await ariaApi.refreshFeed(feedName);
|
||||||
addToast('success', 'Refresh Triggered', res.data.message || `${feedName} feed is refreshing`);
|
addToast('success', 'Refresh Triggered', `${FEED_META[feedName]?.label ?? feedName} is refreshing`);
|
||||||
setTimeout(() => refresh(), 2000);
|
setTimeout(() => { loadFeeds(); loadRuns(runsPage); }, 3000);
|
||||||
} catch {
|
} catch {
|
||||||
addToast('error', 'Refresh Failed', `Could not trigger ${feedName} refresh`);
|
addToast('error', 'Refresh Failed', `Could not refresh ${feedName}`);
|
||||||
} finally {
|
} finally {
|
||||||
refreshingFeed = { ...refreshingFeed, [feedName]: false };
|
refreshingFeed = { ...refreshingFeed, [feedName]: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
function statusClass(s?: string) {
|
||||||
function statusClass(status: string | undefined): string {
|
if (s === 'SUCCESS') return 'success';
|
||||||
if (!status) return 'never';
|
if (s === 'ERROR') return 'error';
|
||||||
if (status === 'SUCCESS') return 'success';
|
if (s === 'RUNNING') return 'running';
|
||||||
if (status === 'FAILED') return 'failed';
|
|
||||||
if (status === 'RUNNING') return 'running';
|
|
||||||
return 'never';
|
return 'never';
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: string | undefined): string {
|
function statusLabel(s?: string) {
|
||||||
if (!status) return 'Never run';
|
if (s === 'SUCCESS') return 'Success';
|
||||||
if (status === 'SUCCESS') return 'Success';
|
if (s === 'ERROR') return 'Error';
|
||||||
if (status === 'FAILED') return 'Failed';
|
if (s === 'RUNNING') return 'Running';
|
||||||
if (status === 'RUNNING') return 'Running';
|
return 'Never run';
|
||||||
return status;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeTime(iso: string | undefined): string {
|
function relativeTime(iso?: string) {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
const sec = Math.floor(diff / 1000);
|
const min = Math.floor(diff / 60000);
|
||||||
const min = Math.floor(sec / 60);
|
const hr = Math.floor(min / 60);
|
||||||
const hr = Math.floor(min / 60);
|
const day = Math.floor(hr / 24);
|
||||||
const day = Math.floor(hr / 24);
|
if (min < 1) return 'just now';
|
||||||
if (sec < 60) return 'just now';
|
if (min < 60) return `${min}m ago`;
|
||||||
if (min < 60) return `${min} minute${min !== 1 ? 's' : ''} ago`;
|
if (hr < 24) return `${hr}h ago`;
|
||||||
if (hr < 24) return `${hr} hour${hr !== 1 ? 's' : ''} ago`;
|
return `${day}d ago`;
|
||||||
return `${day} day${day !== 1 ? 's' : ''} ago`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTs(iso: string | undefined): string {
|
function formatTs(iso?: string) {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
return new Date(iso).toLocaleString('en-US', {
|
return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function duration(start: string, end: string | undefined): string {
|
function duration(start: string, end?: string) {
|
||||||
if (!end) return '—';
|
if (!end) return '—';
|
||||||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
const ms = new Date(end).getTime() - new Date(start).getTime();
|
||||||
if (ms < 1000) return `${ms}ms`;
|
if (ms < 1000) return `${ms}ms`;
|
||||||
@@ -136,18 +129,12 @@
|
|||||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtCount(n: number | undefined): string {
|
|
||||||
if (n == null) return '—';
|
|
||||||
return n.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Auto-refresh every 30 s ───────────────────────────────────────────
|
|
||||||
let pollInterval: ReturnType<typeof setInterval>;
|
let pollInterval: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadFeeds();
|
loadFeeds();
|
||||||
loadRuns(0);
|
loadRuns(0);
|
||||||
pollInterval = setInterval(refresh, 30_000);
|
pollInterval = setInterval(pollStatus, 30_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => clearInterval(pollInterval));
|
onDestroy(() => clearInterval(pollInterval));
|
||||||
@@ -155,85 +142,75 @@
|
|||||||
|
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
|
|
||||||
<!-- Page header -->
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="header-text">
|
<div class="header-text">
|
||||||
<h1>IOC Feeds</h1>
|
<h1>IOC Feeds</h1>
|
||||||
<p>Automated threat intelligence ingestion — OTX, MalwareBazaar, ThreatFox</p>
|
<p>Automated threat intelligence — OTX, MalwareBazaar, ThreatFox</p>
|
||||||
</div>
|
|
||||||
<div class="header-controls">
|
|
||||||
<button
|
|
||||||
class="btn-refresh-all"
|
|
||||||
on:click={refreshAll}
|
|
||||||
disabled={refreshingAll}
|
|
||||||
title="Trigger full refresh of all feeds"
|
|
||||||
>
|
|
||||||
{#if refreshingAll}
|
|
||||||
<span class="spin">⟳</span> Refreshing…
|
|
||||||
{:else}
|
|
||||||
↻ Refresh All
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Feed cards -->
|
<!-- Feed cards -->
|
||||||
<div class="feeds-section">
|
<div class="form-card feeds-card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<span class="count">{feeds.length} feed{feeds.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<button class="btn btn-secondary btn-sm" on:click={() => { loadFeeds(); loadRuns(runsPage); }}>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" on:click={refreshAll} disabled={refreshingAll}>
|
||||||
|
{refreshingAll ? 'Running…' : 'Run All Feeds'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if feedsLoading && feeds.length === 0}
|
{#if feedsLoading && feeds.length === 0}
|
||||||
<div class="feeds-loading">Loading feed status…</div>
|
<div class="loading-msg">Loading feed status…</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="feed-cards">
|
<div class="feed-cards">
|
||||||
{#each orderedFeeds as feed (feed.feedName)}
|
{#each orderedFeeds as feed (feed.feedName)}
|
||||||
{@const meta = FEED_META[feed.feedName] ?? { label: feed.feedName, icon: '📡', requiresKey: false }}
|
{@const meta = FEED_META[feed.feedName] ?? { label: feed.feedName, icon: '📡' }}
|
||||||
<div class="feed-card" class:feed-disabled={!feed.enabled} class:feed-unconfigured={!feed.configured}>
|
<div class="feed-card" class:unconfigured={!feed.configured}>
|
||||||
<div class="feed-card-top">
|
<div class="feed-card-top">
|
||||||
<div class="feed-icon">{meta.icon}</div>
|
<span class="feed-icon">{meta.icon}</span>
|
||||||
<div class="feed-info">
|
<div class="feed-info">
|
||||||
<div class="feed-name">{meta.label}</div>
|
<div class="feed-name">{meta.label}</div>
|
||||||
<div class="feed-id">{feed.feedName}</div>
|
<div class="feed-key">{feed.feedName}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-badge status-{statusClass(feed.lastRunStatus)}">
|
<span class="badge badge-{statusClass(feed.lastRunStatus)}">{statusLabel(feed.lastRunStatus)}</span>
|
||||||
{statusLabel(feed.lastRunStatus)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feed-meta-row">
|
<div class="feed-tag-row">
|
||||||
{#if feed.configured}
|
{#if feed.configured}
|
||||||
<span class="config-tag config-ok">Configured</span>
|
<span class="tag tag-ok">Configured</span>
|
||||||
{:else if meta.requiresKey}
|
|
||||||
<span class="config-tag config-warn">Not configured — API key required</span>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span class="config-tag config-ok">No key required</span>
|
<span class="tag tag-warn">API key required</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feed-stats">
|
<div class="feed-stats">
|
||||||
<div class="feed-stat">
|
<div class="feed-stat">
|
||||||
<span class="feed-stat-label">Last run</span>
|
<span class="stat-label">Last run</span>
|
||||||
<span class="feed-stat-value">{relativeTime(feed.lastRunAt)}</span>
|
<span class="stat-value">{relativeTime(feed.lastRunAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="feed-stat">
|
<div class="feed-stat">
|
||||||
<span class="feed-stat-label">IOCs added</span>
|
<span class="stat-label">IOCs added</span>
|
||||||
<span class="feed-stat-value">{fmtCount(feed.lastIocsAdded)}</span>
|
<span class="stat-value">{feed.lastIocsAdded?.toLocaleString() ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="feed-stat">
|
<div class="feed-stat">
|
||||||
<span class="feed-stat-label">Total from feed</span>
|
<span class="stat-label">Updated</span>
|
||||||
<span class="feed-stat-value">{fmtCount(feed.totalIocsFromFeed)}</span>
|
<span class="stat-value">{feed.lastIocsUpdated?.toLocaleString() ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feed-card-footer">
|
<div class="feed-card-footer">
|
||||||
<button
|
<button
|
||||||
class="btn-feed-refresh"
|
class="btn btn-secondary btn-sm"
|
||||||
on:click={() => refreshSingle(feed.feedName)}
|
on:click={() => refreshSingle(feed.feedName)}
|
||||||
disabled={refreshingFeed[feed.feedName] || !feed.configured}
|
disabled={refreshingFeed[feed.feedName] || !feed.configured}
|
||||||
title={feed.configured ? 'Refresh this feed' : 'Feed not configured'}
|
|
||||||
>
|
>
|
||||||
{#if refreshingFeed[feed.feedName]}
|
{refreshingFeed[feed.feedName] ? 'Running…' : '↻ Run Feed'}
|
||||||
<span class="spin">⟳</span> Refreshing…
|
|
||||||
{:else}
|
|
||||||
↻ Refresh
|
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,14 +219,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Run history table -->
|
<!-- Run history -->
|
||||||
<div class="runs-section">
|
<div class="table-section">
|
||||||
<div class="runs-header">
|
<div class="table-wrapper">
|
||||||
<span class="runs-title">Run History</span>
|
<div class="toolbar">
|
||||||
<span class="runs-count">{runsTotalElements.toLocaleString()} run{runsTotalElements !== 1 ? 's' : ''}</span>
|
<div class="toolbar-left">
|
||||||
</div>
|
<span class="count">{runsTotalElements.toLocaleString()} run{runsTotalElements !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="table-card">
|
|
||||||
<Pagination
|
<Pagination
|
||||||
page={runsPage}
|
page={runsPage}
|
||||||
totalPages={runsTotalPages}
|
totalPages={runsTotalPages}
|
||||||
@@ -260,9 +238,9 @@
|
|||||||
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
{#if runsLoading}
|
{#if runsLoading}
|
||||||
<div class="table-msg">Loading run history…</div>
|
<div class="table-empty">Loading…</div>
|
||||||
{:else if runs.length === 0}
|
{:else if runs.length === 0}
|
||||||
<div class="table-msg">No feed runs recorded yet. Trigger a refresh to see results here.</div>
|
<div class="table-empty">No feed runs yet. Click "Run All Feeds" to start.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -271,8 +249,8 @@
|
|||||||
<th>Started</th>
|
<th>Started</th>
|
||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>IOCs Added</th>
|
<th class="num-col">Added</th>
|
||||||
<th>IOCs Updated</th>
|
<th class="num-col">Updated</th>
|
||||||
<th>Error</th>
|
<th>Error</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -285,24 +263,18 @@
|
|||||||
{FEED_META[run.feedName]?.label ?? run.feedName}
|
{FEED_META[run.feedName]?.label ?? run.feedName}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="date-cell">{formatTs(run.startedAt)}</td>
|
<td class="muted">{formatTs(run.startedAt)}</td>
|
||||||
<td class="mono-cell">{duration(run.startedAt, run.completedAt)}</td>
|
<td class="mono">{duration(run.startedAt, run.completedAt)}</td>
|
||||||
|
<td><span class="badge badge-{statusClass(run.status)}">{statusLabel(run.status)}</span></td>
|
||||||
|
<td class="num-col">{run.iocsAdded.toLocaleString()}</td>
|
||||||
|
<td class="num-col">{run.iocsUpdated.toLocaleString()}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="status-badge status-{statusClass(run.status)}">
|
|
||||||
{statusLabel(run.status)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="num-cell">{run.iocsAdded.toLocaleString()}</td>
|
|
||||||
<td class="num-cell">{run.iocsUpdated.toLocaleString()}</td>
|
|
||||||
<td class="error-cell">
|
|
||||||
{#if run.errorMessage}
|
{#if run.errorMessage}
|
||||||
<span class="error-text" title={run.errorMessage}>
|
<span class="error-text" title={run.errorMessage}>
|
||||||
{run.errorMessage.length > 60
|
{run.errorMessage.length > 60 ? run.errorMessage.substring(0, 60) + '…' : run.errorMessage}
|
||||||
? run.errorMessage.substring(0, 60) + '…'
|
|
||||||
: run.errorMessage}
|
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="no-error">—</span>
|
<span class="muted">—</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -317,275 +289,106 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Layout ──────────────────────────────────────────────────────────── */
|
|
||||||
.page-wrapper {
|
.page-wrapper {
|
||||||
display: flex;
|
display: flex; flex-direction: column;
|
||||||
flex-direction: column;
|
height: 100%; overflow: hidden;
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 24px 24px 0;
|
padding: 24px 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header — dark navy gradient (matches all other ARIA pages) ───────── */
|
/* Feed cards block */
|
||||||
.header-controls { display: flex; align-items: center; gap: 0.75rem; flex-shrink: 0; }
|
.feeds-card { flex-shrink: 0; margin-bottom: 20px; }
|
||||||
|
|
||||||
.btn-refresh-all {
|
.toolbar {
|
||||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
display: flex; align-items: center;
|
||||||
padding: 0.45rem 1rem;
|
justify-content: space-between;
|
||||||
background: rgba(255,255,255,0.15);
|
padding: 10px 16px;
|
||||||
border: 1px solid rgba(255,255,255,0.35);
|
border-bottom: 1px solid #e5e7eb;
|
||||||
color: white; border-radius: 6px;
|
|
||||||
font-size: 0.875rem; font-weight: 600;
|
|
||||||
cursor: pointer; transition: background 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.btn-refresh-all:hover:not(:disabled) { background: rgba(255,255,255,0.28); }
|
.toolbar-left { display: flex; align-items: center; gap: 0.75rem; }
|
||||||
.btn-refresh-all:disabled { opacity: 0.55; cursor: not-allowed; }
|
.toolbar-right { display: flex; gap: 0.5rem; }
|
||||||
|
|
||||||
/* ── Feed cards section ──────────────────────────────────────────────── */
|
.count { font-size: 0.85rem; color: #6b7280; font-weight: 500; }
|
||||||
.feeds-section {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 16px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feeds-loading {
|
.loading-msg { padding: 1.5rem 16px; color: #6b7280; font-size: 0.9rem; }
|
||||||
color: #6b7280;
|
|
||||||
font-size: var(--font-size-body-sm);
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-cards {
|
.feed-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-card {
|
.feed-card {
|
||||||
background: white;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
transition: box-shadow 0.15s;
|
|
||||||
}
|
|
||||||
.feed-card:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.07); }
|
|
||||||
.feed-card.feed-unconfigured { border-color: #fde68a; }
|
|
||||||
.feed-card.feed-disabled { opacity: 0.65; }
|
|
||||||
|
|
||||||
.feed-card-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-icon {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
line-height: 1;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-info { flex: 1; min-width: 0; }
|
|
||||||
.feed-name {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #111827;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.feed-id {
|
|
||||||
font-size: var(--font-size-tiny);
|
|
||||||
color: #9ca3af;
|
|
||||||
font-family: monospace;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Status badges ───────────────────────────────────────────────────── */
|
|
||||||
.status-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.status-success { background: #dcfce7; color: #15803d; }
|
|
||||||
.status-failed { background: #fee2e2; color: #b91c1c; }
|
|
||||||
.status-running { background: #fef9c3; color: #854d0e; }
|
|
||||||
.status-never { background: #f3f4f6; color: #6b7280; }
|
|
||||||
|
|
||||||
/* ── Config tag ──────────────────────────────────────────────────────── */
|
|
||||||
.feed-meta-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
||||||
.config-tag {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: var(--font-size-tiny);
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.config-ok { background: #dcfce7; color: #15803d; }
|
|
||||||
.config-warn { background: #fef3c7; color: #92400e; }
|
|
||||||
|
|
||||||
/* ── Feed stats ──────────────────────────────────────────────────────── */
|
|
||||||
.feed-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
border-top: 1px solid #f3f4f6;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
.feed-stat {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.feed-stat-label {
|
|
||||||
font-size: var(--font-size-tiny);
|
|
||||||
color: #9ca3af;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.feed-stat-value {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Per-card refresh button ─────────────────────────────────────────── */
|
|
||||||
.feed-card-footer {
|
|
||||||
border-top: 1px solid #f3f4f6;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
.btn-feed-refresh {
|
|
||||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
|
||||||
padding: 0.35rem 0.85rem;
|
|
||||||
background: #f1f5f9;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
color: #374151;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.82rem; font-weight: 500;
|
|
||||||
cursor: pointer; transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-feed-refresh:hover:not(:disabled) { background: #e2e8f0; }
|
|
||||||
.btn-feed-refresh:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
/* ── Run history section ─────────────────────────────────────────────── */
|
|
||||||
.runs-section {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
padding-top: 20px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runs-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.runs-title {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f2937;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
}
|
|
||||||
.runs-count {
|
|
||||||
font-size: var(--font-size-tiny);
|
|
||||||
color: #9ca3af;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Table card — canonical ──────────────────────────────────────────── */
|
|
||||||
.table-card {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
padding: 14px;
|
||||||
flex: 1;
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
display: flex;
|
background: #fafafa;
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
.table-container { flex: 1; overflow: auto; }
|
.feed-card.unconfigured { border-color: #fde68a; background: #fffbeb; }
|
||||||
.table-msg { padding: 2rem; text-align: center; color: #6b7280; font-size: var(--font-size-body-sm); }
|
|
||||||
|
|
||||||
table { width: 100%; border-collapse: collapse; }
|
.feed-card-top { display: flex; align-items: flex-start; gap: 10px; }
|
||||||
thead { background: #f9fafb; position: sticky; top: 0; z-index: 5; }
|
.feed-icon { font-size: 1.5rem; flex-shrink: 0; line-height: 1; margin-top: 1px; }
|
||||||
th {
|
.feed-info { flex: 1; min-width: 0; }
|
||||||
color: #374151; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.3px;
|
.feed-name { font-size: 0.9rem; font-weight: 700; color: #111827; }
|
||||||
font-weight: 600; border-bottom: 2px solid #e5e7eb; padding: 0.6rem 0.75rem;
|
.feed-key { font-size: 0.72rem; color: #9ca3af; font-family: monospace; margin-top: 1px; }
|
||||||
text-align: left; white-space: nowrap;
|
|
||||||
|
/* Status badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
padding: 2px 9px; border-radius: 20px;
|
||||||
|
font-size: 0.72rem; font-weight: 700;
|
||||||
|
white-space: nowrap; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
td {
|
.badge-success { background: #dcfce7; color: #15803d; }
|
||||||
border-bottom: 1px solid #f3f4f6;
|
.badge-error { background: #fee2e2; color: #b91c1c; }
|
||||||
color: #1f2937;
|
.badge-running { background: #fef9c3; color: #854d0e; }
|
||||||
padding: 0.55rem 0.75rem;
|
.badge-never { background: #f3f4f6; color: #6b7280; }
|
||||||
font-size: var(--font-size-label);
|
|
||||||
vertical-align: middle;
|
/* Config tag */
|
||||||
|
.feed-tag-row { display: flex; gap: 6px; }
|
||||||
|
.tag { font-size: 0.72rem; font-weight: 600; padding: 2px 7px; border-radius: 4px; }
|
||||||
|
.tag-ok { background: #dcfce7; color: #15803d; }
|
||||||
|
.tag-warn { background: #fef3c7; color: #92400e; }
|
||||||
|
|
||||||
|
/* Stats row */
|
||||||
|
.feed-stats {
|
||||||
|
display: flex; gap: 12px;
|
||||||
|
border-top: 1px solid #f3f4f6; padding-top: 8px;
|
||||||
|
}
|
||||||
|
.feed-stat { display: flex; flex-direction: column; gap: 1px; flex: 1; }
|
||||||
|
.stat-label { font-size: 0.68rem; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.4px; font-weight: 600; }
|
||||||
|
.stat-value { font-size: 0.85rem; font-weight: 700; color: #1f2937; }
|
||||||
|
|
||||||
|
.feed-card-footer { border-top: 1px solid #f3f4f6; padding-top: 8px; }
|
||||||
|
|
||||||
|
/* Run history table */
|
||||||
|
.table-section {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
overflow: hidden; min-height: 0; padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
tbody tr:hover { background: #eff6ff; }
|
|
||||||
tbody tr:last-child td { border-bottom: none; }
|
|
||||||
|
|
||||||
.feed-name-cell { display: flex; align-items: center; gap: 6px; font-weight: 500; white-space: nowrap; }
|
.feed-name-cell { display: flex; align-items: center; gap: 6px; font-weight: 500; white-space: nowrap; }
|
||||||
.date-cell { white-space: nowrap; color: #6b7280; }
|
.muted { color: #6b7280; }
|
||||||
.mono-cell { font-family: monospace; font-size: 0.78rem; color: #4b5563; }
|
.mono { font-family: monospace; font-size: 0.78rem; }
|
||||||
.num-cell { text-align: right; font-variant-numeric: tabular-nums; padding-right: 1.25rem; }
|
.num-col { text-align: right; padding-right: 1.25rem; font-variant-numeric: tabular-nums; }
|
||||||
.error-cell { max-width: 260px; }
|
|
||||||
.error-text { color: #dc2626; font-size: 0.78rem; cursor: help; }
|
.error-text { color: #dc2626; font-size: 0.78rem; cursor: help; }
|
||||||
.no-error { color: #9ca3af; }
|
|
||||||
|
|
||||||
/* ── Spinner animation ───────────────────────────────────────────────── */
|
/* Dark mode */
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
:global(.dark-mode) .feed-card { background: #1e293b; border-color: #334155; }
|
||||||
.spin { display: inline-block; animation: spin 0.8s linear infinite; }
|
:global(.dark-mode) .feed-card.unconfigured { border-color: #78350f; background: #1c1208; }
|
||||||
|
:global(.dark-mode) .feed-name { color: #f1f5f9; }
|
||||||
|
:global(.dark-mode) .stat-value { color: #e2e8f0; }
|
||||||
|
:global(.dark-mode) .feed-stats,
|
||||||
|
:global(.dark-mode) .feed-card-footer { border-top-color: #334155; }
|
||||||
|
|
||||||
/* ── Dark mode ───────────────────────────────────────────────────────── */
|
:global(.dark-mode) .badge-success { background: #052e16; color: #86efac; }
|
||||||
:global(.dark-mode) .feed-card {
|
:global(.dark-mode) .badge-error { background: #450a0a; color: #fca5a5; }
|
||||||
background: #1f2937;
|
:global(.dark-mode) .badge-running { background: #422006; color: #fcd34d; }
|
||||||
border-color: #374151;
|
:global(.dark-mode) .badge-never { background: #1e293b; color: #94a3b8; }
|
||||||
}
|
:global(.dark-mode) .tag-ok { background: #052e16; color: #86efac; }
|
||||||
:global(.dark-mode) .feed-card.feed-unconfigured { border-color: #78350f; }
|
:global(.dark-mode) .tag-warn { background: #422006; color: #fcd34d; }
|
||||||
:global(.dark-mode) .feed-name { color: #f3f4f6; }
|
:global(.dark-mode) .toolbar { border-bottom-color: #334155; }
|
||||||
:global(.dark-mode) .feed-stats { border-top-color: #374151; }
|
:global(.dark-mode) .muted { color: #94a3b8; }
|
||||||
:global(.dark-mode) .feed-stat-value { color: #e5e7eb; }
|
|
||||||
:global(.dark-mode) .feed-card-footer { border-top-color: #374151; }
|
|
||||||
:global(.dark-mode) .feeds-loading { color: #9ca3af; }
|
|
||||||
|
|
||||||
:global(.dark-mode) .status-success { background: #052e16; color: #86efac; }
|
|
||||||
:global(.dark-mode) .status-failed { background: #450a0a; color: #fca5a5; }
|
|
||||||
:global(.dark-mode) .status-running { background: #422006; color: #fcd34d; }
|
|
||||||
:global(.dark-mode) .status-never { background: #1e293b; color: #94a3b8; }
|
|
||||||
|
|
||||||
:global(.dark-mode) .config-ok { background: #052e16; color: #86efac; }
|
|
||||||
:global(.dark-mode) .config-warn { background: #422006; color: #fcd34d; }
|
|
||||||
|
|
||||||
:global(.dark-mode) .btn-feed-refresh {
|
|
||||||
background: #374151; color: #e5e7eb; border-color: #4b5563;
|
|
||||||
}
|
|
||||||
:global(.dark-mode) .btn-feed-refresh:hover:not(:disabled) { background: #4b5563; }
|
|
||||||
|
|
||||||
:global(.dark-mode) .runs-title { color: #e5e7eb; }
|
|
||||||
:global(.dark-mode) .runs-count { color: #6b7280; }
|
|
||||||
|
|
||||||
:global(.dark-mode) .table-card { background: #1f2937; border-color: #374151; }
|
|
||||||
:global(.dark-mode) thead { background: #111827; }
|
|
||||||
:global(.dark-mode) th { background: #111827; color: #9ca3af; border-bottom-color: #374151; }
|
|
||||||
:global(.dark-mode) td { color: #e5e7eb; border-bottom-color: #374151; }
|
|
||||||
:global(.dark-mode) tbody tr { background: #1f2937; }
|
|
||||||
:global(.dark-mode) tbody tr:hover { background: #374151; }
|
|
||||||
:global(.dark-mode) .date-cell { color: #9ca3af; }
|
|
||||||
:global(.dark-mode) .mono-cell { color: #94a3b8; }
|
|
||||||
:global(.dark-mode) .error-text { color: #f87171; }
|
:global(.dark-mode) .error-text { color: #f87171; }
|
||||||
:global(.dark-mode) .no-error { color: #6b7280; }
|
|
||||||
:global(.dark-mode) .table-msg { color: #9ca3af; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export type IocFeedStatus = {
|
|||||||
lastRunAt?: string;
|
lastRunAt?: string;
|
||||||
lastRunStatus?: string;
|
lastRunStatus?: string;
|
||||||
lastIocsAdded?: number;
|
lastIocsAdded?: number;
|
||||||
totalIocsFromFeed?: number;
|
lastIocsUpdated?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IocFeedRun = {
|
export type IocFeedRun = {
|
||||||
@@ -190,5 +190,5 @@ export const ariaApi = {
|
|||||||
api.post<{ message: string }>('/api/aria/feeds/refresh'),
|
api.post<{ message: string }>('/api/aria/feeds/refresh'),
|
||||||
|
|
||||||
refreshFeed: (feedName: string) =>
|
refreshFeed: (feedName: string) =>
|
||||||
api.post<{ message: string }>(`/api/aria/feeds/${feedName}/refresh`),
|
api.post<{ message: string }>(`/api/aria/feeds/refresh/${feedName}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import com.hiveops.aria.repository.IocFeedRunRepository;
|
|||||||
import com.hiveops.aria.service.IocFeedService;
|
import com.hiveops.aria.service.IocFeedService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.*;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/aria/feeds")
|
@RequestMapping("/api/aria/feeds")
|
||||||
@@ -20,6 +19,9 @@ import java.util.Optional;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class IocFeedController {
|
public class IocFeedController {
|
||||||
|
|
||||||
|
private final IocFeedService feedService;
|
||||||
|
private final IocFeedRunRepository feedRunRepository;
|
||||||
|
|
||||||
private Thread feedThread(String name, Runnable task) {
|
private Thread feedThread(String name, Runnable task) {
|
||||||
Thread t = new Thread(task, name);
|
Thread t = new Thread(task, name);
|
||||||
t.setUncaughtExceptionHandler((thread, ex) ->
|
t.setUncaughtExceptionHandler((thread, ex) ->
|
||||||
@@ -27,30 +29,36 @@ public class IocFeedController {
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
private final IocFeedService feedService;
|
/** One status object per feed — array, matches IocFeedStatus[] in the frontend */
|
||||||
private final IocFeedRunRepository feedRunRepository;
|
|
||||||
|
|
||||||
/** Latest run per feed — used by the status cards in the UI */
|
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
@PreAuthorize("hasRole('BCOS_ADMIN')")
|
@PreAuthorize("hasRole('BCOS_ADMIN')")
|
||||||
public ResponseEntity<Map<String, Object>> status() {
|
public ResponseEntity<List<Map<String, Object>>> status() {
|
||||||
List<String> names = feedService.feedNames();
|
List<Map<String, Object>> result = new ArrayList<>();
|
||||||
Map<String, Object> result = new java.util.LinkedHashMap<>();
|
for (String name : feedService.feedNames()) {
|
||||||
for (String name : names) {
|
|
||||||
Optional<IocFeedRun> last = feedRunRepository.findTopByFeedNameOrderByStartedAtDesc(name);
|
Optional<IocFeedRun> last = feedRunRepository.findTopByFeedNameOrderByStartedAtDesc(name);
|
||||||
result.put(name, last.orElse(null));
|
Map<String, Object> entry = new LinkedHashMap<>();
|
||||||
|
entry.put("feedName", name);
|
||||||
|
entry.put("configured", feedService.isConfigured(name));
|
||||||
|
entry.put("enabled", true);
|
||||||
|
last.ifPresent(r -> {
|
||||||
|
entry.put("lastRunAt", r.getCompletedAt() != null ? r.getCompletedAt() : r.getStartedAt());
|
||||||
|
entry.put("lastRunStatus", r.getStatus());
|
||||||
|
entry.put("lastIocsAdded", r.getIocsAdded());
|
||||||
|
entry.put("lastIocsUpdated", r.getIocsUpdated());
|
||||||
|
});
|
||||||
|
result.add(entry);
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recent run history — default 50 rows */
|
/** Paginated run history — returns Spring Page so the frontend gets content + page metadata */
|
||||||
@GetMapping("/runs")
|
@GetMapping("/runs")
|
||||||
@PreAuthorize("hasRole('BCOS_ADMIN')")
|
@PreAuthorize("hasRole('BCOS_ADMIN')")
|
||||||
public ResponseEntity<List<IocFeedRun>> runs(
|
public ResponseEntity<Page<IocFeedRun>> runs(
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "50") int size) {
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
feedRunRepository.findAllByOrderByStartedAtDesc(PageRequest.of(page, size)).getContent()
|
feedRunRepository.findAllByOrderByStartedAtDesc(PageRequest.of(page, size))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ public class IocFeedService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<String> feedNames() {
|
public List<String> feedNames() {
|
||||||
return List.copyOf(FEED_SOURCES.keySet());
|
return List.of("OTX", "MALWARE_BAZAAR", "THREAT_FOX");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConfigured(String feedName) {
|
||||||
|
return switch (feedName) {
|
||||||
|
case "OTX" -> otxClient.isConfigured();
|
||||||
|
case "MALWARE_BAZAAR" -> malwareBazaarClient.isConfigured();
|
||||||
|
case "THREAT_FOX" -> threatFoxClient.isConfigured();
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user