[TSM.ID].[11031972] PXE : Platform X Ecosystem I [118 Module -LIVE-]
This commit is contained in:
@@ -0,0 +1,696 @@
|
||||
// [TSM.ID].[11031972] -- All Rights Reserved. Proprietary & Confidential.
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useCommercial } from '../context/CommercialContext';
|
||||
import DangerModal from './DangerModal';
|
||||
|
||||
import { useModuleRegistry } from '../context/ModuleRegistryContext';
|
||||
|
||||
function useDebounce(value, delay) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export default function TenantArchitect() {
|
||||
const { tiers } = useCommercial();
|
||||
const { registry } = useModuleRegistry();
|
||||
|
||||
const API_BASE = import.meta.env.VITE_XCU_API_URL || '';
|
||||
|
||||
// Load Tenants dari API Gateway (xcu_iam PostgreSQL)
|
||||
const [tenants, setTenants] = useState([]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isProvisioning, setIsProvisioning] = useState(false);
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const { cryptographicKey } = useAuth();
|
||||
|
||||
// State for Form (Create/Edit)
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [newTenantName, setNewTenantName] = useState('');
|
||||
const [customTierName, setCustomTierName] = useState('');
|
||||
const [selectedModules, setSelectedModules] = useState({});
|
||||
const [showProvisionForm, setShowProvisionForm] = useState(false);
|
||||
|
||||
const handleAutoFill = (tierId) => {
|
||||
const selectedTier = tiers.find(t => t.id === tierId);
|
||||
if (!selectedTier) return;
|
||||
|
||||
// Convert array of modules to object { m1: true, m2: true }
|
||||
const newSelection = {};
|
||||
selectedTier.modules.forEach(mod => {
|
||||
newSelection[mod] = true;
|
||||
});
|
||||
setSelectedModules(newSelection);
|
||||
};
|
||||
|
||||
// Omni-Dimensional Search States
|
||||
const [moduleSearchQuery, setModuleSearchQuery] = useState('');
|
||||
const debouncedModuleQuery = useDebounce(moduleSearchQuery, 250);
|
||||
|
||||
const [tenantSearchQuery, setTenantSearchQuery] = useState('');
|
||||
const debouncedTenantQuery = useDebounce(tenantSearchQuery, 250);
|
||||
|
||||
// Fetch tenants dari API Gateway
|
||||
const fetchTenants = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/xcu-api/v1/tenants`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
// Transform API data to component format
|
||||
const mapped = data.map(t => ({
|
||||
id: t.tenant_key || t.id,
|
||||
name: t.tenant_name || t.name,
|
||||
status: t.status || 'PROVISIONED',
|
||||
baseTierId: t.base_tier_id || t.baseTierId || null,
|
||||
customModules: t.custom_modules || t.customModules || [],
|
||||
customTierName: t.custom_tier_name || t.customTierName || '',
|
||||
created: t.created_at ? t.created_at.split('T')[0] : (t.created || new Date().toISOString().split('T')[0])
|
||||
}));
|
||||
setTenants(mapped);
|
||||
setApiError(null);
|
||||
console.log(`[XCU TENANT] Loaded ${mapped.length} tenants from API`);
|
||||
} catch (err) {
|
||||
console.warn('[XCU TENANT] API offline, using fallback:', err.message);
|
||||
setApiError(err.message);
|
||||
// Fallback: seed JUMPA.ID jika API offline
|
||||
if (tenants.length === 0) {
|
||||
setTenants([{
|
||||
id: 'AEGIS-TENANT-80AA3EEBE8-X',
|
||||
name: 'JUMPA.ID ENTERPRISE',
|
||||
status: 'PROVISIONED',
|
||||
baseTierId: 'tier_jumpa_omni',
|
||||
customModules: [],
|
||||
customTierName: '',
|
||||
created: '2026-05-08'
|
||||
}]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTenants();
|
||||
}, []);
|
||||
|
||||
// Fungsi Pembantu: Dapatkan Modul Asli untuk Tenant (Auto-Sync dengan Blueprint)
|
||||
const resolveTenantModules = (tenant) => {
|
||||
if (tenant.baseTierId) {
|
||||
const tier = tiers.find(t => t.id === tenant.baseTierId);
|
||||
if (tier) return tier.modules;
|
||||
}
|
||||
return tenant.customModules || [];
|
||||
};
|
||||
|
||||
const resolveTierBadge = (tenant) => {
|
||||
const activeModules = resolveTenantModules(tenant);
|
||||
if (!activeModules || activeModules.length === 0) return { name: 'BLIND (0 MODUL)', color: 'var(--accent-red)' };
|
||||
|
||||
if (tenant.baseTierId) {
|
||||
const tierIndex = tiers.findIndex(t => t.id === tenant.baseTierId);
|
||||
if (tierIndex !== -1) {
|
||||
let themeColor = 'var(--accent-cyan)';
|
||||
if (tierIndex === 1) themeColor = 'var(--accent-yellow)';
|
||||
if (tierIndex === 2) themeColor = 'var(--accent-red)';
|
||||
if (tierIndex === 3) themeColor = 'var(--accent-green)';
|
||||
if (tierIndex >= 4) themeColor = 'var(--text-main)';
|
||||
return { name: tiers[tierIndex].name, color: themeColor };
|
||||
}
|
||||
}
|
||||
|
||||
const finalName = tenant.customTierName ? `CUSTOM FORGE: ${tenant.customTierName}` : 'CUSTOM FORGE';
|
||||
return { name: finalName, color: 'var(--accent-purple)' };
|
||||
};
|
||||
|
||||
const toggleModule = (moduleId) => {
|
||||
setSelectedModules(prev => ({
|
||||
...prev,
|
||||
[moduleId]: !prev[moduleId]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditClick = (tenant) => {
|
||||
setEditingId(tenant.id);
|
||||
setNewTenantName(tenant.name);
|
||||
setCustomTierName(tenant.customTierName || '');
|
||||
setShowProvisionForm(true);
|
||||
// Konversi array modul kembali ke object untuk state
|
||||
const modState = {};
|
||||
tenant.modules.forEach(m => modState[m] = true);
|
||||
setSelectedModules(modState);
|
||||
|
||||
// Auto-scroll ke atas
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const [selectedTenants, setSelectedTenants] = useState([]);
|
||||
const [deleteModal, setDeleteModal] = useState({ isOpen: false, tenantIds: [] });
|
||||
|
||||
const handleDeleteClick = (tenantId) => {
|
||||
setDeleteModal({ isOpen: true, tenantIds: [tenantId] });
|
||||
};
|
||||
|
||||
const handleBulkDeleteClick = () => {
|
||||
setDeleteModal({ isOpen: true, tenantIds: selectedTenants });
|
||||
};
|
||||
|
||||
const toggleSelectTenant = (id) => {
|
||||
setSelectedTenants(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (deleteModal.tenantIds.length > 0) {
|
||||
// Delete via API for each tenant
|
||||
for (const tenantId of deleteModal.tenantIds) {
|
||||
try {
|
||||
await fetch(`${API_BASE}/xcu-api/v1/tenants/${encodeURIComponent(tenantId)}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
console.error('[XCU TENANT] Delete error:', err.message);
|
||||
}
|
||||
}
|
||||
// Refresh from API
|
||||
await fetchTenants();
|
||||
if (deleteModal.tenantIds.includes(editingId)) {
|
||||
cancelEdit();
|
||||
}
|
||||
setSelectedTenants([]);
|
||||
}
|
||||
setDeleteModal({ isOpen: false, tenantIds: [] });
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
setDeleteModal({ isOpen: false, tenantIds: [] });
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setNewTenantName('');
|
||||
setCustomTierName('');
|
||||
setSelectedModules({});
|
||||
setShowProvisionForm(false);
|
||||
};
|
||||
|
||||
const saveTenant = async () => {
|
||||
if (!newTenantName) return alert("Nama Tenant tidak boleh kosong.");
|
||||
setIsProvisioning(true);
|
||||
|
||||
// Konversi object { id: true/false } menjadi array of allowed IDs
|
||||
const allowedModules = Object.keys(selectedModules).filter(id => selectedModules[id]);
|
||||
|
||||
// Check if the allowedModules matches any Tier exactly
|
||||
let matchedTierId = null;
|
||||
const sortedAllowed = [...allowedModules].sort().join(',');
|
||||
for (const tier of tiers) {
|
||||
if ([...tier.modules].sort().join(',') === sortedAllowed) {
|
||||
matchedTierId = tier.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
// UPDATE via API
|
||||
await fetch(`${API_BASE}/xcu-api/v1/tenants/${encodeURIComponent(editingId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tenant_name: newTenantName,
|
||||
base_tier_id: matchedTierId,
|
||||
custom_modules: matchedTierId ? [] : allowedModules,
|
||||
custom_tier_name: matchedTierId ? '' : customTierName
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// CREATE via API
|
||||
const tenantKey = 'AEGIS-TENANT-' + newTenantName.replace(/[^A-Z0-9]/gi, '').toUpperCase().slice(0, 10) + '-' + Math.random().toString(16).substr(2, 4).toUpperCase();
|
||||
await fetch(`${API_BASE}/xcu-api/v1/tenants`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tenant_key: tenantKey,
|
||||
tenant_name: newTenantName,
|
||||
base_tier_id: matchedTierId,
|
||||
custom_modules: matchedTierId ? [] : allowedModules,
|
||||
custom_tier_name: matchedTierId ? '' : customTierName
|
||||
})
|
||||
});
|
||||
}
|
||||
// Refresh from API
|
||||
await fetchTenants();
|
||||
} catch (err) {
|
||||
console.error('[XCU TENANT] Save error:', err.message);
|
||||
setApiError(err.message);
|
||||
}
|
||||
|
||||
cancelEdit();
|
||||
setIsProvisioning(false);
|
||||
};
|
||||
|
||||
const isEditMode = editingId !== null;
|
||||
const borderColor = isEditMode ? 'var(--accent-yellow)' : 'rgba(0, 243, 255, 0.2)';
|
||||
const themeColor = isEditMode ? 'var(--accent-yellow)' : 'var(--accent-cyan)';
|
||||
|
||||
const filteredTenants = tenants.filter(t => {
|
||||
if (!debouncedTenantQuery) return true;
|
||||
const q = debouncedTenantQuery.toLowerCase();
|
||||
if (t.name.toLowerCase().includes(q) || t.id.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Omni-Search: Cek nama modul aslinya dari Registry
|
||||
const activeModules = resolveTenantModules(t);
|
||||
return activeModules.some(modId => {
|
||||
for (let coreKey in registry) {
|
||||
const found = registry[coreKey].modules.find(m => m.id === modId);
|
||||
if (found && found.name.toLowerCase().includes(q)) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedTenants.length === filteredTenants.length && filteredTenants.length > 0) {
|
||||
setSelectedTenants([]);
|
||||
} else {
|
||||
setSelectedTenants(filteredTenants.map(t => t.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateTether = (tenant) => {
|
||||
// Generate Quantum Tether (.xcu) file
|
||||
const tetherData = {
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
baseTierId: tenant.baseTierId,
|
||||
customModules: tenant.customModules,
|
||||
endpoint: "wss://xcu ULTRA.ultramodul.xyz/quantum-relay",
|
||||
signature: btoa(tenant.id + "-" + Date.now())
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(tetherData, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${tenant.id.toLowerCase()}.xcu`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass-panel" style={{padding: '30px', borderColor: 'var(--accent-cyan)'}}>
|
||||
<style>{`
|
||||
@keyframes laserScan {
|
||||
0% { transform: scaleX(0); transform-origin: left; opacity: 0.8;}
|
||||
50% { transform: scaleX(1); transform-origin: left; opacity: 1;}
|
||||
50.1% { transform: scaleX(1); transform-origin: right; opacity: 1;}
|
||||
100% { transform: scaleX(0); transform-origin: right; opacity: 0.8;}
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 243, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 243, 255, 0.6);
|
||||
}
|
||||
`}</style>
|
||||
<DangerModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
title={deleteModal.tenantIds.length > 1 ? "MASS ANNIHILATION PROTOCOL" : "REVOKE COMMERCIAL GATEWAY"}
|
||||
message={`PERINGATAN ABSOLUT: Anda akan memusnahkan ${deleteModal.tenantIds.length} Entitas Klien secara permanen dari Matriks. Semua layanan WebRTC dan Sinkronisasi Data mereka akan MATI TOTAL tanpa bisa dipulihkan. Lanjutkan eksekusi?`}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={cancelDelete}
|
||||
confirmText={deleteModal.tenantIds.length > 1 ? "EXECUTE MASS ANNIHILATION" : "ANNIHILATE TENANT"}
|
||||
/>
|
||||
<div style={{marginBottom: '30px'}}>
|
||||
<h2 style={{color: 'var(--text-main)', marginBottom: '8px', fontFamily: 'monospace', letterSpacing: '2px'}}>THE TENANT ARCHITECT</h2>
|
||||
<p style={{color: 'var(--text-muted)'}}>Source: <span style={{color: apiError ? 'var(--accent-yellow)' : 'var(--accent-green)', fontWeight: 'bold'}}>{apiError ? 'Static Fallback' : 'xcu_iam PostgreSQL'}</span> | <span style={{color: 'var(--accent-cyan)'}}>{tenants.length}</span> Tenants <button onClick={fetchTenants} style={{marginLeft:'10px',padding:'4px 12px',background:'transparent',border:'1px solid var(--accent-green)',color:'var(--accent-green)',borderRadius:'4px',cursor:'pointer',fontFamily:'monospace',fontSize:'0.75rem'}}>↻ REFRESH</button></p>
|
||||
</div>
|
||||
|
||||
{!showProvisionForm && !isEditMode && (
|
||||
<div style={{marginBottom: '40px'}}>
|
||||
<button
|
||||
onClick={() => setShowProvisionForm(true)}
|
||||
style={{
|
||||
width: '100%', padding: '20px', background: 'rgba(168, 85, 247, 0.1)',
|
||||
border: '1px dashed var(--accent-purple)', color: 'var(--accent-purple)', borderRadius: '12px',
|
||||
cursor: 'pointer', fontFamily: 'monospace', fontSize: '1.2rem',
|
||||
letterSpacing: '3px', fontWeight: 'bold', transition: 'all 0.3s ease',
|
||||
boxShadow: '0 0 20px rgba(168, 85, 247, 0.2)'
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(168, 85, 247, 0.3)';
|
||||
e.currentTarget.style.boxShadow = '0 0 40px rgba(168, 85, 247, 0.6)';
|
||||
e.currentTarget.style.color = 'var(--text-main)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(168, 85, 247, 0.1)';
|
||||
e.currentTarget.style.boxShadow = '0 0 20px rgba(168, 85, 247, 0.2)';
|
||||
e.currentTarget.style.color = 'var(--accent-purple)';
|
||||
}}
|
||||
>
|
||||
+ SPAWN NEW TENANT ENTITY
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(showProvisionForm || isEditMode) && (
|
||||
<div style={{background: 'var(--panel-bg)', padding: '25px', borderRadius: '12px', border: `1px solid ${borderColor}`, marginBottom: '40px', transition: 'border 0.3s ease', animation: 'fadeIn 0.3s ease-out'}}>
|
||||
<h3 style={{color: themeColor, marginBottom: '20px', fontSize: '1rem', display: 'flex', justifyContent: 'space-between'}}>
|
||||
{isEditMode ? `[ EDITING TENANT: ${editingId} ]` : 'PROVISION NEW TENANT'}
|
||||
<span style={{color: 'var(--text-muted)', cursor: 'pointer', fontSize: '0.8rem'}} onClick={cancelEdit}>[ Batal / Tutup ]</span>
|
||||
</h3>
|
||||
|
||||
<div style={{marginBottom: '20px'}}>
|
||||
<label style={{display: 'block', color: 'var(--text-muted)', fontSize: '0.8rem', marginBottom: '8px', letterSpacing: '1px'}}>TENANT ENTITY NAME</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTenantName}
|
||||
onChange={(e) => setNewTenantName(e.target.value)}
|
||||
placeholder="e.g., JUMPA.ID ENTERPRISE"
|
||||
style={{
|
||||
width: '100%', padding: '12px', background: 'var(--hover-bg)',
|
||||
border: `1px solid ${isEditMode ? 'rgba(255, 234, 0, 0.3)' : 'rgba(255,255,255,0.1)'}`, color: 'var(--text-main)',
|
||||
borderRadius: '6px', fontFamily: 'monospace', outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{display: 'flex', gap: '20px', marginBottom: '25px'}}>
|
||||
<div style={{flex: 1}}>
|
||||
<label style={{display: 'block', color: 'var(--text-muted)', fontSize: '0.8rem', marginBottom: '8px', letterSpacing: '1px'}}>AUTO-FILL DARI PAKET KOMERSIAL</label>
|
||||
<div style={{display: 'flex', gap: '10px'}}>
|
||||
<select
|
||||
onChange={(e) => handleAutoFill(e.target.value)}
|
||||
style={{
|
||||
flex: 1, padding: '12px', background: 'var(--hover-bg)',
|
||||
border: '1px solid rgba(255,255,255,0.1)', color: 'var(--text-main)',
|
||||
borderRadius: '6px', fontFamily: 'monospace', outline: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled style={{color: '#000'}}>-- Pilih Preset Paket (Opsional) --</option>
|
||||
{tiers.map(t => (
|
||||
<option key={t.id} value={t.id} style={{color: '#000'}}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setSelectedModules({})}
|
||||
style={{
|
||||
padding: '0 20px', background: 'rgba(255,0,60,0.1)', border: '1px solid var(--accent-red)',
|
||||
color: 'var(--accent-red)', borderRadius: '6px', cursor: 'pointer', fontFamily: 'monospace'
|
||||
}}
|
||||
>
|
||||
RESET
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{flex: 1}}>
|
||||
<label style={{display: 'block', color: 'var(--accent-purple)', fontSize: '0.8rem', marginBottom: '8px', letterSpacing: '1px'}}>NAMA PAKET KUSTOM (Jika Custom Forge)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customTierName}
|
||||
onChange={(e) => setCustomTierName(e.target.value)}
|
||||
placeholder="e.g., SS9 GOV MATRIX"
|
||||
style={{
|
||||
width: '100%', padding: '12px', background: 'rgba(168,85,247,0.05)',
|
||||
border: '1px solid rgba(168,85,247,0.3)', color: 'var(--text-main)',
|
||||
borderRadius: '6px', fontFamily: 'monospace', outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{marginBottom: '25px'}}>
|
||||
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: '15px'}}>
|
||||
<label style={{display: 'block', color: 'var(--text-muted)', fontSize: '0.8rem', letterSpacing: '1px'}}>MODUL 1-75 ALLOCATION MATRIX</label>
|
||||
<div style={{position: 'relative', width: '300px'}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Decryption Search (e.g., 'Aegis')..."
|
||||
value={moduleSearchQuery}
|
||||
onChange={(e) => setModuleSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: '8px 12px', background: 'rgba(0, 243, 255, 0.05)',
|
||||
border: '1px solid rgba(0, 243, 255, 0.3)', color: 'var(--accent-cyan)',
|
||||
borderRadius: '6px', fontFamily: 'monospace', outline: 'none', fontSize: '0.8rem',
|
||||
boxShadow: moduleSearchQuery ? '0 0 10px rgba(0,243,255,0.2)' : 'none',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
/>
|
||||
{moduleSearchQuery && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, height: '2px', background: 'var(--accent-cyan)',
|
||||
animation: 'laserScan 1.5s infinite', width: '100%'
|
||||
}}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display: 'flex', flexDirection: 'column', gap: '20px', maxHeight: '600px', overflowY: 'auto', paddingRight: '10px'}} className="custom-scrollbar">
|
||||
{Object.keys(registry).map(coreKey => {
|
||||
const core = registry[coreKey];
|
||||
// Omni-Index Filtering
|
||||
const filteredModules = core.modules.filter(mod =>
|
||||
!debouncedModuleQuery ||
|
||||
mod.name.toLowerCase().includes(debouncedModuleQuery.toLowerCase()) ||
|
||||
mod.id.toLowerCase().includes(debouncedModuleQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (filteredModules.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={coreKey} style={{background: 'var(--hover-bg)', padding: '15px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)', animation: 'fadeIn 0.4s ease-out'}}>
|
||||
<h4 style={{color: 'var(--text-muted)', fontSize: '0.75rem', marginBottom: '12px', borderBottom: '1px solid rgba(255,255,255,0.1)', paddingBottom: '5px'}}>
|
||||
{core.name} <span style={{color: themeColor, float: 'right'}}>[ {filteredModules.length} ]</span>
|
||||
</h4>
|
||||
<div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px'}}>
|
||||
{filteredModules.map(mod => (
|
||||
<div
|
||||
key={mod.id}
|
||||
onClick={() => toggleModule(mod.id)}
|
||||
style={{
|
||||
padding: '10px',
|
||||
background: selectedModules[mod.id] ? (isEditMode ? 'rgba(255, 234, 0, 0.15)' : 'rgba(0, 243, 255, 0.15)') : 'rgba(255,255,255,0.02)',
|
||||
border: `1px solid ${selectedModules[mod.id] ? themeColor : 'rgba(255,255,255,0.1)'}`,
|
||||
borderRadius: '6px', cursor: 'pointer', display: 'flex', alignItems: 'center', transition: 'all 0.2s',
|
||||
boxShadow: debouncedModuleQuery && selectedModules[mod.id] ? `0 0 15px ${themeColor}40` : 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '16px', height: '16px', borderRadius: '4px',
|
||||
border: `1px solid ${selectedModules[mod.id] ? themeColor : '#555'}`,
|
||||
background: selectedModules[mod.id] ? themeColor : 'transparent',
|
||||
marginRight: '10px'
|
||||
}}></div>
|
||||
<span style={{color: selectedModules[mod.id] ? 'var(--text-main)' : 'var(--text-muted)', fontSize: '0.8rem'}}>{mod.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn-primary ${isProvisioning ? 'processing' : ''}`}
|
||||
onClick={saveTenant}
|
||||
disabled={isProvisioning || !newTenantName}
|
||||
style={{
|
||||
width: '100%', padding: '15px', fontSize: '1rem',
|
||||
background: isEditMode ? 'rgba(255, 234, 0, 0.1)' : 'transparent',
|
||||
borderColor: themeColor, color: themeColor
|
||||
}}
|
||||
>
|
||||
{isProvisioning ? '[ SYNCHRONIZING MATRIX... ]' : (isEditMode ? 'UPDATE TENANT PROTOCOL' : 'ENGAGE PROVISIONING PROTOCOL')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: '20px', marginTop: '40px'}}>
|
||||
<h3 style={{color: 'var(--text-main)', fontSize: '1rem', margin: 0}}>GLOBAL ACTIVE TENANTS</h3>
|
||||
<div style={{position: 'relative', width: '300px'}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Klien / ID / Modul..."
|
||||
value={tenantSearchQuery}
|
||||
onChange={(e) => setTenantSearchQuery(e.target.value)}
|
||||
style={{
|
||||
width: '100%', padding: '8px 12px', background: 'rgba(255, 0, 60, 0.05)',
|
||||
border: '1px solid rgba(255, 0, 60, 0.3)', color: 'var(--accent-red)',
|
||||
borderRadius: '6px', fontFamily: 'monospace', outline: 'none', fontSize: '0.8rem',
|
||||
boxShadow: tenantSearchQuery ? '0 0 10px rgba(255,0,60,0.2)' : 'none',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
/>
|
||||
{tenantSearchQuery && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, height: '2px', background: 'var(--accent-red)',
|
||||
animation: 'laserScan 1.5s infinite reverse', width: '100%'
|
||||
}}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div style={{textAlign: 'center', padding: '30px', color: 'var(--accent-cyan)'}}>Menarik Struktur Omni-Tenant...</div>
|
||||
) : (
|
||||
<table className="glass-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width: '40px', textAlign: 'center'}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={handleSelectAll}
|
||||
checked={selectedTenants.length === filteredTenants.length && filteredTenants.length > 0}
|
||||
style={{cursor: 'pointer', accentColor: 'var(--accent-red)'}}
|
||||
/>
|
||||
</th>
|
||||
<th>Cryptographic Key ID</th>
|
||||
<th>Tenant Name</th>
|
||||
<th>Active Modules</th>
|
||||
<th>Override</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTenants.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="5" style={{textAlign: 'center', color: 'var(--text-muted)', padding: '30px'}}>Tidak ada Entitas Klien yang cocok dengan pencarian.</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredTenants.map((t, idx) => {
|
||||
const badge = resolveTierBadge(t);
|
||||
const activeModules = resolveTenantModules(t);
|
||||
|
||||
return (
|
||||
<tr key={t.id} style={{
|
||||
background: editingId === t.id ? 'rgba(255, 234, 0, 0.05)' : (selectedTenants.includes(t.id) ? 'rgba(255, 0, 60, 0.1)' : 'transparent'),
|
||||
animation: 'fadeIn 0.5s ease-out',
|
||||
transition: 'background 0.3s'
|
||||
}}>
|
||||
<td style={{textAlign: 'center'}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTenants.includes(t.id)}
|
||||
onChange={() => toggleSelectTenant(t.id)}
|
||||
style={{cursor: 'pointer', accentColor: 'var(--accent-red)'}}
|
||||
/>
|
||||
</td>
|
||||
<td style={{fontFamily: 'monospace', color: editingId === t.id ? 'var(--accent-yellow)' : 'var(--accent-cyan)'}}>{t.id}</td>
|
||||
<td style={{fontWeight: 'bold', color: 'var(--text-main)'}}>
|
||||
<div style={{marginBottom: '5px'}}>{t.name}</div>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
background: `${badge.color}20`,
|
||||
border: `1px solid ${badge.color}`,
|
||||
color: badge.color,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.7rem',
|
||||
fontFamily: 'monospace',
|
||||
boxShadow: `0 0 10px ${badge.color}40`,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1px'
|
||||
}}>
|
||||
{badge.name}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{color: 'var(--text-muted)', fontSize: '0.8rem'}}>
|
||||
{activeModules && activeModules.length > 0 ? (
|
||||
<div style={{display: 'flex', flexWrap: 'wrap', gap: '4px'}}>
|
||||
{activeModules.map(m => {
|
||||
let modName = m;
|
||||
for (let coreKey in registry) {
|
||||
const found = registry[coreKey].modules.find(x => x.id === m);
|
||||
if (found) modName = found.name;
|
||||
}
|
||||
const isMatch = debouncedTenantQuery && modName.toLowerCase().includes(debouncedTenantQuery.toLowerCase());
|
||||
return (
|
||||
<span key={m} style={{
|
||||
background: isMatch ? 'rgba(255,0,60,0.2)' : 'var(--bg-panel)',
|
||||
border: isMatch ? '1px solid var(--accent-red)' : '1px solid var(--table-border)',
|
||||
color: isMatch ? 'var(--text-main)' : 'var(--text-main)',
|
||||
padding: '2px 6px', borderRadius: '4px'
|
||||
}}>
|
||||
{modName}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <span style={{color: 'var(--accent-red)'}}>BLIND (0 MODUL)</span>}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{display: 'flex', gap: '8px', flexWrap: 'wrap'}}>
|
||||
<button onClick={() => handleGenerateTether(t)} style={{
|
||||
background: 'rgba(0, 243, 255, 0.1)', border: '1px solid var(--accent-cyan)',
|
||||
color: 'var(--accent-cyan)', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem', boxShadow: '0 0 10px rgba(0, 243, 255, 0.2)'
|
||||
}}>GENERATE TETHER (.xcu)</button>
|
||||
|
||||
<button onClick={() => handleEditClick(t)} style={{
|
||||
background: 'rgba(255,234,0,0.1)', border: '1px solid var(--accent-yellow)',
|
||||
color: 'var(--accent-yellow)', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem'
|
||||
}}>EDIT / REFORGE</button>
|
||||
|
||||
<button onClick={() => handleDeleteClick(t.id)} style={{
|
||||
background: 'rgba(255,0,60,0.1)', border: '1px solid var(--accent-red)',
|
||||
color: 'var(--accent-red)', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer',
|
||||
fontFamily: 'monospace', fontSize: '0.75rem'
|
||||
}}>REVOKE</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{selectedTenants.length > 0 && (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: '20px', left: '50%', transform: 'translateX(-50%)',
|
||||
background: 'var(--panel-bg)', border: '1px solid var(--accent-red)', borderRadius: '12px',
|
||||
padding: '20px 40px', display: 'flex', alignItems: 'center', gap: '30px',
|
||||
boxShadow: '0 0 30px rgba(255,0,60,0.5)', zIndex: 1000, animation: 'fadeIn 0.3s ease-out'
|
||||
}}>
|
||||
<div style={{color: 'var(--accent-red)', fontFamily: 'monospace', fontSize: '1.2rem', fontWeight: 'bold'}}>
|
||||
{selectedTenants.length} ENTITAS KLIEN SIAP DIMUSNAHKAN
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBulkDeleteClick}
|
||||
style={{
|
||||
background: 'var(--accent-red)', color: '#000', border: 'none', padding: '10px 20px',
|
||||
borderRadius: '6px', fontWeight: 'bold', fontFamily: 'monospace', cursor: 'pointer',
|
||||
textTransform: 'uppercase', letterSpacing: '1px', boxShadow: '0 0 15px rgba(255,0,60,0.8)'
|
||||
}}
|
||||
>
|
||||
MASS ANNIHILATION PROTOCOL
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user