Add Ivanti Workflows panel with API key auth and SQLite cache
- New panel below Archer tickets showing workflow count and list - Backend proxies platform4.risksense.com workflowBatch/search via x-api-key - SQLite cache table (ivanti_sync_state) stores latest sync result - Auto-syncs on server startup if >24h stale, then every 24h via setInterval - POST /api/ivanti/workflows/sync for on-demand sync with spinner feedback - GET /api/ivanti/workflows returns cached data instantly (no live API call) - Displays id.value, name, currentState, type, createdOn per workflow - Shows last-synced timestamp and error messages inline - IVANTI_SKIP_TLS flag for Charter SSL proxy environments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,3 +6,12 @@ CORS_ORIGINS=http://localhost:3000
|
|||||||
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
# NVD API Key (optional - increases rate limit from 5 to 50 requests per 30s)
|
||||||
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
# Request one at https://nvd.nist.gov/developers/request-an-api-key
|
||||||
NVD_API_KEY=
|
NVD_API_KEY=
|
||||||
|
|
||||||
|
# Ivanti / RiskSense API (platform4.risksense.com)
|
||||||
|
# API key from your profile settings — does not expire like session cookies
|
||||||
|
IVANTI_API_KEY=
|
||||||
|
IVANTI_CLIENT_ID=1550
|
||||||
|
IVANTI_FIRST_NAME=
|
||||||
|
IVANTI_LAST_NAME=
|
||||||
|
# Set to true if behind Charter's SSL inspection proxy (replicates Python verify=False)
|
||||||
|
IVANTI_SKIP_TLS=false
|
||||||
|
|||||||
37
backend/migrations/add_ivanti_sync_table.js
Normal file
37
backend/migrations/add_ivanti_sync_table.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Migration: Add ivanti_sync_state table
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'cve_database.db');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
console.log('Starting Ivanti sync state migration...');
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
workflows_json TEXT DEFAULT '[]',
|
||||||
|
synced_at DATETIME,
|
||||||
|
sync_status TEXT DEFAULT 'never',
|
||||||
|
error_message TEXT
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error creating table:', err);
|
||||||
|
else console.log('✓ ivanti_sync_state table created');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed the single-row state record
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => {
|
||||||
|
if (err) console.error('Error seeding state row:', err);
|
||||||
|
else console.log('✓ ivanti_sync_state row seeded');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.close(() => {
|
||||||
|
console.log('Migration complete!');
|
||||||
|
});
|
||||||
274
backend/routes/ivantiWorkflows.js
Normal file
274
backend/routes/ivantiWorkflows.js
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
// Ivanti / RiskSense Workflow Routes
|
||||||
|
// Data is cached in SQLite and refreshed on a daily schedule or on-demand.
|
||||||
|
// Auth: x-api-key header (confirmed via platform4.risksense.com/doc/swagger.json)
|
||||||
|
// Error codes: 401 bad key, 419 insufficient privileges, 429 rate limited
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const IVANTI_URL_BASE = 'https://platform4.risksense.com/api/v1';
|
||||||
|
const SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP helper — uses Node's https module directly so we can toggle
|
||||||
|
// rejectUnauthorized for Charter's SSL inspection proxy (IVANTI_SKIP_TLS=true)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function ivantiPost(urlPath, body, apiKey, skipTls) {
|
||||||
|
const bodyStr = JSON.stringify(body);
|
||||||
|
const fullUrl = new URL(IVANTI_URL_BASE + urlPath);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: fullUrl.hostname,
|
||||||
|
path: fullUrl.pathname + fullUrl.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'accept': '*/*',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'x-http-client-type': 'browser',
|
||||||
|
'content-length': Buffer.byteLength(bodyStr)
|
||||||
|
},
|
||||||
|
rejectUnauthorized: !skipTls,
|
||||||
|
timeout: 15000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => req.destroy(new Error('Request timed out')));
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(bodyStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Ensure the sync state table exists (idempotent — safe to call on every start)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function initTable(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ivanti_sync_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
total INTEGER DEFAULT 0,
|
||||||
|
workflows_json TEXT DEFAULT '[]',
|
||||||
|
synced_at DATETIME,
|
||||||
|
sync_status TEXT DEFAULT 'never',
|
||||||
|
error_message TEXT
|
||||||
|
)
|
||||||
|
`, (err) => { if (err) return reject(err); });
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
INSERT OR IGNORE INTO ivanti_sync_state (id, total, workflows_json, sync_status)
|
||||||
|
VALUES (1, 0, '[]', 'never')
|
||||||
|
`, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core sync — calls Ivanti API, stores result in SQLite
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function syncWorkflows(db) {
|
||||||
|
const apiKey = process.env.IVANTI_API_KEY;
|
||||||
|
const clientId = process.env.IVANTI_CLIENT_ID || '1550';
|
||||||
|
const firstName = process.env.IVANTI_FIRST_NAME || '';
|
||||||
|
const lastName = process.env.IVANTI_LAST_NAME || '';
|
||||||
|
const skipTls = process.env.IVANTI_SKIP_TLS === 'true';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
const errMsg = 'IVANTI_API_KEY not set in .env — skipping sync';
|
||||||
|
console.warn('[Ivanti]', errMsg);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[errMsg], resolve
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Ivanti] Syncing workflows...');
|
||||||
|
|
||||||
|
const urlPath = `/client/${encodeURIComponent(clientId)}/workflowBatch/search`;
|
||||||
|
const body = {
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
field: 'created_by_last_name',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: lastName,
|
||||||
|
caseSensitive: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'created_by_first_name',
|
||||||
|
exclusive: false,
|
||||||
|
operator: 'IN',
|
||||||
|
orWithPrevious: false,
|
||||||
|
implicitFilters: [],
|
||||||
|
value: firstName,
|
||||||
|
caseSensitive: false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
projection: 'internal',
|
||||||
|
sort: [{ field: 'created', direction: 'DESC' }],
|
||||||
|
page: 0,
|
||||||
|
size: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ivantiPost(urlPath, body, apiKey, skipTls);
|
||||||
|
|
||||||
|
if (result.status === 401) {
|
||||||
|
throw new Error('Invalid or missing API key (401) — check IVANTI_API_KEY in .env');
|
||||||
|
}
|
||||||
|
if (result.status === 419) {
|
||||||
|
throw new Error('Insufficient privileges (419) — API key lacks workflow access');
|
||||||
|
}
|
||||||
|
if (result.status === 429) {
|
||||||
|
throw new Error('Rate limited (429) — will retry at next scheduled sync');
|
||||||
|
}
|
||||||
|
if (result.status !== 200) {
|
||||||
|
throw new Error(`Ivanti API returned unexpected status ${result.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(result.body);
|
||||||
|
|
||||||
|
// Spring Data REST format: { _embedded: { workflowBatches: [...] }, page: { totalElements, ... } }
|
||||||
|
let total = 0;
|
||||||
|
let workflows = [];
|
||||||
|
|
||||||
|
if (data.page && typeof data.page.totalElements === 'number') {
|
||||||
|
total = data.page.totalElements;
|
||||||
|
workflows = data._embedded?.workflowBatches
|
||||||
|
|| data._embedded?.workflowBatch
|
||||||
|
|| [];
|
||||||
|
} else if (typeof data.total === 'number') {
|
||||||
|
total = data.total;
|
||||||
|
workflows = data.data || data.content || data.results || [];
|
||||||
|
} else if (typeof data.totalElements === 'number') {
|
||||||
|
total = data.totalElements;
|
||||||
|
workflows = data.content || data.data || [];
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
workflows = data;
|
||||||
|
total = data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE ivanti_sync_state
|
||||||
|
SET total=?, workflows_json=?, synced_at=datetime('now'), sync_status='success', error_message=NULL
|
||||||
|
WHERE id=1`,
|
||||||
|
[total, JSON.stringify(workflows)],
|
||||||
|
(err) => { if (err) reject(err); else resolve(); }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Ivanti] Sync complete — ${total} workflows`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || 'Unknown error';
|
||||||
|
console.error('[Ivanti] Sync failed:', msg);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE ivanti_sync_state SET sync_status='error', error_message=?, synced_at=datetime('now') WHERE id=1`,
|
||||||
|
[msg], resolve
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scheduler — runs sync immediately if >24h stale, then every 24h
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function scheduleSync(db) {
|
||||||
|
db.get('SELECT synced_at FROM ivanti_sync_state WHERE id = 1', (err, row) => {
|
||||||
|
if (err || !row || !row.synced_at) {
|
||||||
|
syncWorkflows(db);
|
||||||
|
} else {
|
||||||
|
const lastSync = new Date(row.synced_at.replace(' ', 'T') + 'Z');
|
||||||
|
const hoursSince = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60);
|
||||||
|
if (hoursSince >= 24) {
|
||||||
|
syncWorkflows(db);
|
||||||
|
} else {
|
||||||
|
const hoursUntil = (24 - hoursSince).toFixed(1);
|
||||||
|
console.log(`[Ivanti] Last sync ${hoursSince.toFixed(1)}h ago — next auto-sync in ${hoursUntil}h`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => syncWorkflows(db), SYNC_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper — read current state from DB and return as JSON-ready object
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function readState(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT total, workflows_json, synced_at, sync_status, error_message FROM ivanti_sync_state WHERE id = 1',
|
||||||
|
(err, row) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (!row) return resolve({ total: 0, workflows: [], synced_at: null, sync_status: 'never', error_message: null });
|
||||||
|
|
||||||
|
let workflows = [];
|
||||||
|
try { workflows = JSON.parse(row.workflows_json || '[]'); } catch (_) { /* leave empty */ }
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
total: row.total || 0,
|
||||||
|
workflows,
|
||||||
|
synced_at: row.synced_at,
|
||||||
|
sync_status: row.sync_status,
|
||||||
|
error_message: row.error_message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Router
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function createIvantiWorkflowsRouter(db, requireAuth) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Init table and kick off scheduler (fire-and-forget on startup)
|
||||||
|
initTable(db)
|
||||||
|
.then(() => scheduleSync(db))
|
||||||
|
.catch((err) => console.error('[Ivanti] Init failed:', err));
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(requireAuth(db));
|
||||||
|
|
||||||
|
// GET / — return cached data (fast, no external call)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await readState(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Database error reading sync state' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /sync — trigger an immediate sync, await completion, return fresh state
|
||||||
|
router.post('/sync', async (req, res) => {
|
||||||
|
await syncWorkflows(db);
|
||||||
|
try {
|
||||||
|
res.json(await readState(db));
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Sync ran but could not read updated state' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = createIvantiWorkflowsRouter;
|
||||||
@@ -21,6 +21,7 @@ const createNvdLookupRouter = require('./routes/nvdLookup');
|
|||||||
const createWeeklyReportsRouter = require('./routes/weeklyReports');
|
const createWeeklyReportsRouter = require('./routes/weeklyReports');
|
||||||
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
const createKnowledgeBaseRouter = require('./routes/knowledgeBase');
|
||||||
const createArcherTicketsRouter = require('./routes/archerTickets');
|
const createArcherTicketsRouter = require('./routes/archerTickets');
|
||||||
|
const createIvantiWorkflowsRouter = require('./routes/ivantiWorkflows');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
@@ -183,6 +184,9 @@ app.use('/api/knowledge-base', createKnowledgeBaseRouter(db, upload));
|
|||||||
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
// Archer tickets routes (editor/admin for create/update/delete, all authenticated for view)
|
||||||
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
app.use('/api/archer-tickets', createArcherTicketsRouter(db));
|
||||||
|
|
||||||
|
// Ivanti / RiskSense workflow routes (all authenticated users)
|
||||||
|
app.use('/api/ivanti/workflows', createIvantiWorkflowsRouter(db, requireAuth));
|
||||||
|
|
||||||
// ========== CVE ENDPOINTS ==========
|
// ========== CVE ENDPOINTS ==========
|
||||||
|
|
||||||
// Get all CVEs with optional filters (authenticated users)
|
// Get all CVEs with optional filters (authenticated users)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield } from 'lucide-react';
|
import { Search, FileText, AlertCircle, Download, Upload, Eye, Filter, CheckCircle, XCircle, Loader, Trash2, Plus, RefreshCw, Edit2, ChevronDown, Shield, Activity } from 'lucide-react';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
import LoginForm from './components/LoginForm';
|
import LoginForm from './components/LoginForm';
|
||||||
import UserMenu from './components/UserMenu';
|
import UserMenu from './components/UserMenu';
|
||||||
@@ -221,6 +221,15 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor }
|
const [addArcherTicketContext, setAddArcherTicketContext] = useState(null); // { cve_id, vendor }
|
||||||
|
|
||||||
|
// Ivanti workflows state
|
||||||
|
const [ivantiTotal, setIvantiTotal] = useState(null);
|
||||||
|
const [ivantiWorkflows, setIvantiWorkflows] = useState([]);
|
||||||
|
const [ivantiSyncedAt, setIvantiSyncedAt] = useState(null);
|
||||||
|
const [ivantiSyncStatus, setIvantiSyncStatus] = useState(null);
|
||||||
|
const [ivantiSyncError, setIvantiSyncError] = useState(null);
|
||||||
|
const [ivantiLoading, setIvantiLoading] = useState(false);
|
||||||
|
const [ivantiSyncing, setIvantiSyncing] = useState(false);
|
||||||
|
|
||||||
const toggleCVEExpand = (cveId) => {
|
const toggleCVEExpand = (cveId) => {
|
||||||
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
setExpandedCVEs(prev => ({ ...prev, [cveId]: !prev[cveId] }));
|
||||||
};
|
};
|
||||||
@@ -333,6 +342,43 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyIvantiState = (data) => {
|
||||||
|
setIvantiTotal(data.total ?? 0);
|
||||||
|
setIvantiWorkflows(data.workflows || []);
|
||||||
|
setIvantiSyncedAt(data.synced_at || null);
|
||||||
|
setIvantiSyncStatus(data.sync_status || null);
|
||||||
|
setIvantiSyncError(data.error_message || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchIvantiWorkflows = async () => {
|
||||||
|
setIvantiLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/ivanti/workflows`, { credentials: 'include' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) applyIvantiState(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading Ivanti workflows:', err);
|
||||||
|
} finally {
|
||||||
|
setIvantiLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncIvantiWorkflows = async () => {
|
||||||
|
setIvantiSyncing(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/ivanti/workflows/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) applyIvantiState(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error syncing Ivanti workflows:', err);
|
||||||
|
} finally {
|
||||||
|
setIvantiSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchDocuments = async (cveId, vendor) => {
|
const fetchDocuments = async (cveId, vendor) => {
|
||||||
const key = `${cveId}-${vendor}`;
|
const key = `${cveId}-${vendor}`;
|
||||||
if (cveDocuments[key]) return;
|
if (cveDocuments[key]) return;
|
||||||
@@ -861,6 +907,7 @@ export default function App() {
|
|||||||
fetchVendors();
|
fetchVendors();
|
||||||
fetchJiraTickets();
|
fetchJiraTickets();
|
||||||
fetchArcherTickets();
|
fetchArcherTickets();
|
||||||
|
fetchIvantiWorkflows();
|
||||||
fetchKnowledgeBaseArticles();
|
fetchKnowledgeBaseArticles();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -2347,6 +2394,97 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Ivanti Workflows */}
|
||||||
|
<div style={{...STYLES.intelCard, padding: '1.5rem', borderLeft: '3px solid #0D9488'}} className="rounded-lg">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<h2 style={{ fontSize: '1.125rem', fontWeight: '600', color: '#0D9488', display: 'flex', alignItems: 'center', gap: '0.5rem', fontFamily: 'monospace', textTransform: 'uppercase', letterSpacing: '0.1em', textShadow: '0 0 12px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
<Activity className="w-5 h-5" />
|
||||||
|
Ivanti Workflows
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={syncIvantiWorkflows}
|
||||||
|
disabled={ivantiSyncing || ivantiLoading}
|
||||||
|
className="intel-button intel-button-primary flex items-center gap-1 text-xs px-2 py-1"
|
||||||
|
title="Sync now"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${ivantiSyncing ? 'animate-spin' : ''}`} />
|
||||||
|
{ivantiSyncing ? 'Syncing…' : 'Sync'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last synced line */}
|
||||||
|
<div className="text-xs text-gray-500 font-mono mb-4">
|
||||||
|
{ivantiSyncedAt
|
||||||
|
? `Synced ${new Date(ivantiSyncedAt.replace(' ', 'T') + 'Z').toLocaleString()}`
|
||||||
|
: 'Never synced'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ivantiLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader className="w-6 h-6 text-teal-400 animate-spin mx-auto mb-2" />
|
||||||
|
<p className="text-xs text-gray-400 font-mono">Loading...</p>
|
||||||
|
</div>
|
||||||
|
) : ivantiSyncStatus === 'error' ? (
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
{ivantiTotal ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2 p-2 rounded" style={{ background: 'rgba(239, 68, 68, 0.1)', border: '1px solid rgba(239, 68, 68, 0.3)' }}>
|
||||||
|
<AlertCircle className="w-4 h-4 text-intel-danger mt-0.5 shrink-0" />
|
||||||
|
<p className="text-xs text-red-400 font-mono">{ivantiSyncError}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<div style={{ fontSize: '2rem', fontWeight: '700', fontFamily: 'monospace', color: '#0D9488', textShadow: '0 0 16px rgba(13, 148, 136, 0.4)' }}>
|
||||||
|
{ivantiSyncStatus === 'never' ? '—' : (ivantiTotal ?? '—')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400 uppercase tracking-wider">Total Workflows</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{ivantiWorkflows.slice(0, 10).map((wf, idx) => (
|
||||||
|
<div key={wf.uuid ?? idx} style={{ background: 'linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(51, 65, 85, 0.75) 100%)', border: '1px solid rgba(13, 148, 136, 0.25)', borderRadius: '0.375rem', padding: '0.5rem', boxShadow: '0 2px 6px rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.03)' }}>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<span className="font-mono text-xs font-semibold text-teal-300">
|
||||||
|
{wf.id?.value || wf.uuid?.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
{wf.currentState && (
|
||||||
|
<span style={{ fontSize: '0.65rem', padding: '0.2rem 0.4rem', borderRadius: '0.25rem', background: 'rgba(13, 148, 136, 0.2)', border: '1px solid #0D9488', color: '#0D9488', whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
|
||||||
|
{wf.currentState}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white truncate mb-1">{wf.name}</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
{wf.type && (
|
||||||
|
<span className="text-xs text-gray-400 font-mono">{wf.type.replace(/_/g, ' ')}</span>
|
||||||
|
)}
|
||||||
|
{wf.createdOn && (
|
||||||
|
<span className="text-xs text-gray-500">{wf.createdOn}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{ivantiSyncStatus !== 'never' && ivantiTotal === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle className="w-8 h-8 text-intel-success mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-400 italic font-mono">No workflows found</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ivantiSyncStatus === 'never' && (
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<p className="text-xs text-gray-500 font-mono">Click Sync to load workflow data</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* End Right Panel */}
|
{/* End Right Panel */}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user