feat: state persistence — Terminal + Explorer survive navigation (Zustand stores)

This commit is contained in:
nogueiraguh
2026-04-02 09:13:25 -03:00
parent 8caa032055
commit 60596e790f
5 changed files with 401 additions and 244 deletions

View File

@@ -1096,6 +1096,93 @@ async def test_oci(cid: str, u=Depends(require("admin","user"))):
return {"status":"error","message":str(e)[:500]} return {"status":"error","message":str(e)[:500]}
# ── OCI CLI Terminal ───────────────────────────────────────────────────────── # ── OCI CLI Terminal ─────────────────────────────────────────────────────────
# OCID resource type → OCI CLI get command mapping
_OCID_CMD_MAP = {
"instance": "oci compute instance get --instance-id",
"image": "oci compute image get --image-id",
"vcn": "oci network vcn get --vcn-id",
"subnet": "oci network subnet get --subnet-id",
"securitylist": "oci network security-list get --security-list-id",
"routetable": "oci network route-table get --rt-id",
"internetgateway": "oci network internet-gateway get --ig-id",
"natgateway": "oci network nat-gateway get --nat-gateway-id",
"servicegateway": "oci network service-gateway get --service-gateway-id",
"drg": "oci network drg get --drg-id",
"drgattachment": "oci network drg-attachment get --drg-attachment-id",
"localpeeringgateway": "oci network local-peering-gateway get --local-peering-gateway-id",
"networksecuritygroup": "oci network nsg get --nsg-id",
"vnic": "oci network vnic get --vnic-id",
"privateip": "oci network private-ip get --private-ip-id",
"publicip": "oci network public-ip get --public-ip-id",
"dhcpoptions": "oci network dhcp-options get --dhcp-id",
"cpe": "oci network cpe get --cpe-id",
"ipsecconnection": "oci network ip-sec-connection get --ipsc-id",
"volume": "oci bv volume get --volume-id",
"bootvolume": "oci bv boot-volume get --boot-volume-id",
"volumebackup": "oci bv backup get --volume-backup-id",
"bootvolumereplica": "oci bv boot-volume-replica get --boot-volume-replica-id",
"volumegroup": "oci bv volume-group get --volume-group-id",
"bucket": "oci os bucket get --bucket-name",
"compartment": "oci iam compartment get --compartment-id",
"tenancy": "oci iam tenancy get --tenancy-id",
"user": "oci iam user get --user-id",
"group": "oci iam group get --group-id",
"policy": "oci iam policy get --policy-id",
"dynamicgroup": "oci iam dynamic-group get --dynamic-group-id",
"identityprovider": "oci iam identity-provider get --identity-provider-id",
"autonomousdatabase": "oci db autonomous-database get --autonomous-database-id",
"dbsystem": "oci db system get --db-system-id",
"database": "oci db database get --database-id",
"dbhome": "oci db db-home get --db-home-id",
"dbnode": "oci db node get --db-node-id",
"mysqldbsystem": "oci mysql db-system get --db-system-id",
"loadbalancer": "oci lb load-balancer get --load-balancer-id",
"networkloadbalancer": "oci nlb network-load-balancer get --network-load-balancer-id",
"certificate": "oci certs-mgmt certificate get --certificate-id",
"vault": "oci kms management vault get --vault-id",
"key": "oci kms management key get --key-id",
"secret": "oci vault secret get --secret-id",
"fnapp": "oci fn application get --application-id",
"fnfunc": "oci fn function get --function-id",
"apigateway": "oci api-gateway gateway get --gateway-id",
"containerinstance": "oci container-instances container-instance get --container-instance-id",
"cluster": "oci ce cluster get --cluster-id",
"nodepool": "oci ce node-pool get --node-pool-id",
"filesystem": "oci fs file-system get --file-system-id",
"mounttarget": "oci fs mount-target get --mount-target-id",
"alarm": "oci monitoring alarm get --alarm-id",
"loggroup": "oci logging log-group get --log-group-id",
"log": "oci logging log get --log-id",
"topic": "oci ons topic get --topic-id",
"subscription": "oci ons subscription get --subscription-id",
"stream": "oci streaming stream get --stream-id",
"streampool": "oci streaming stream-pool get --stream-pool-id",
"dnszone": "oci dns zone get --zone-name-or-id",
"networkfirewall": "oci network-firewall network-firewall get --network-firewall-id",
"networkfirewallpolicy": "oci network-firewall network-firewall-policy get --network-firewall-policy-id",
"waaspolicy": "oci waas waas-policy get --waas-policy-id",
"instancepool": "oci compute-management instance-pool get --instance-pool-id",
"instanceconfiguration": "oci compute-management instance-configuration get --instance-configuration-id",
"capacityreservation": "oci compute capacity-reservation get --capacity-reservation-id",
"dedicatedvmhost": "oci compute dedicated-vm-host get --dedicated-vm-host-id",
}
def _resolve_ocid_command(ocid: str) -> str | None:
"""Parse an OCID and return the corresponding OCI CLI get command."""
parts = ocid.strip().split(".")
if len(parts) < 4 or parts[0] != "ocid1":
return None
resource_type = parts[1].lower()
# Extract region from OCID if present (4th part, non-empty for regional resources)
region = parts[3] if len(parts) > 3 and parts[3] and parts[3] != parts[2] else None
cmd_template = _OCID_CMD_MAP.get(resource_type)
if not cmd_template:
return None
cmd = f"{cmd_template} {ocid}"
if region:
cmd += f" --region {region}"
return cmd
_TERMINAL_BLOCKED_PATTERNS = re.compile( _TERMINAL_BLOCKED_PATTERNS = re.compile(
r'(rm\s|mv\s|cp\s|chmod\s|chown\s|sudo\s|bash\s|sh\s|curl\s|wget\s|python|pip\s|apt\s|yum\s|>\s|>>\s|\|)', r'(rm\s|mv\s|cp\s|chmod\s|chown\s|sudo\s|bash\s|sh\s|curl\s|wget\s|python|pip\s|apt\s|yum\s|>\s|>>\s|\|)',
re.IGNORECASE re.IGNORECASE
@@ -1111,7 +1198,32 @@ async def terminal_execute(req: dict, u=Depends(current_user)):
if not command: if not command:
raise HTTPException(400, "Comando vazio") raise HTTPException(400, "Comando vazio")
_verify_config_access("oci", oci_config_id, u) _verify_config_access("oci", oci_config_id, u)
# Auto-lookup: OCID pasted directly → resolve to oci get command
resolved_cmd = None
if command.startswith("ocid1."):
resolved_cmd = _resolve_ocid_command(command)
if resolved_cmd:
command = resolved_cmd
else:
raise HTTPException(400, f"Tipo de recurso não reconhecido no OCID: {command.split('.')[1]}")
# find <query> → search resources by display name using OCI Search service
elif command.startswith("find "):
query = command[5:].strip()
if not query:
raise HTTPException(400, "Uso: find <nome-do-recurso> | find %partial% | find 10.0.1.5")
# Detect IP address → search by IP
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query):
search_query = f"query privateip resources where ipAddress = '{query}'"
elif '%' in query:
# Partial match with LIKE
search_query = f"query all resources where displayName =~ '{query}'"
else:
search_query = f"query all resources where displayName = '{query}'"
resolved_cmd = f"oci search resource structured-search --query-text \"{search_query}\" --limit 20"
command = resolved_cmd
# Security: only allow 'oci' commands # Security: only allow 'oci' commands
if command == "oci":
command = "oci --help"
if not command.startswith("oci "): if not command.startswith("oci "):
raise HTTPException(400, "Apenas comandos 'oci' são permitidos. Ex: oci iam user list") raise HTTPException(400, "Apenas comandos 'oci' são permitidos. Ex: oci iam user list")
if _TERMINAL_BLOCKED_PATTERNS.search(command): if _TERMINAL_BLOCKED_PATTERNS.search(command):
@@ -1141,6 +1253,7 @@ async def terminal_execute(req: dict, u=Depends(current_user)):
"exit_code": exit_code, "exit_code": exit_code,
"output": output[:50000], "output": output[:50000],
"error": error[:5000] if exit_code != 0 else "", "error": error[:5000] if exit_code != 0 else "",
"resolved_cmd": resolved_cmd,
} }
except asyncio.TimeoutError: except asyncio.TimeoutError:
with db() as c: with db() as c:

View File

@@ -890,18 +890,21 @@ export default function ExplorerPage() {
/* ── State (from store for user selections) ──────────────────────────── */ /* ── State (from store for user selections) ──────────────────────────── */
const selectedConfig = store.expSelectedConfig; const selectedConfig = store.expSelectedConfig;
const setSelectedConfig = store.setExpSelectedConfig; const setSelectedConfig = store.setExpSelectedConfig;
const [compartmentTree, setCompartmentTree] = useState<TreeNode[]>([]); const compartmentTree = store.expCompartmentTree as TreeNode[];
const setCompartmentTree = store.setExpCompartmentTree;
const selectedCompartment = store.expSelectedCompartment; const selectedCompartment = store.expSelectedCompartment;
const setSelectedCompartment = store.setExpSelectedCompartment; const setSelectedCompartment = store.setExpSelectedCompartment;
const selectedRegions = store.expSelectedRegions; const selectedRegions = store.expSelectedRegions;
const setSelectedRegions = store.setExpSelectedRegions; const setSelectedRegions = store.setExpSelectedRegions;
const [availableRegions, setAvailableRegions] = useState<Region[]>([]); const availableRegions = store.expAvailableRegions as Region[];
const setAvailableRegions = store.setExpAvailableRegions;
const selectedCategory = store.expSelectedCategory; const selectedCategory = store.expSelectedCategory;
const setSelectedCategory = store.setExpSelectedCategory; const setSelectedCategory = store.setExpSelectedCategory;
const selectedResourceType = store.expSelectedResourceType; const selectedResourceType = store.expSelectedResourceType;
const setSelectedResourceType = store.setExpSelectedResourceType; const setSelectedResourceType = store.setExpSelectedResourceType;
const [resourceData, setResourceData] = useState<ResourceItem[] | null>(null); const [resourceData, setResourceData] = useState<ResourceItem[] | null>(null);
const [counts, setCounts] = useState<Record<string, number>>({}); const counts = store.expCounts;
const setCounts = store.setExpCounts;
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [countsRefreshing, setCountsRefreshing] = useState(false); const [countsRefreshing, setCountsRefreshing] = useState(false);
const [treeOpen, setTreeOpen] = useState<Record<string, boolean>>({}); const [treeOpen, setTreeOpen] = useState<Record<string, boolean>>({});
@@ -913,6 +916,7 @@ export default function ExplorerPage() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef(false); const draggingRef = useRef(false);
const prevConfigRef = useRef(selectedConfig);
/* ── Pick first config ──────────────────────────────────────────────────── */ /* ── Pick first config ──────────────────────────────────────────────────── */
useEffect(() => { useEffect(() => {
@@ -922,12 +926,18 @@ export default function ExplorerPage() {
/* ── Load tree + regions on config change ───────────────────────────────── */ /* ── Load tree + regions on config change ───────────────────────────────── */
useEffect(() => { useEffect(() => {
if (!selectedConfig) return; if (!selectedConfig) return;
const configChanged = prevConfigRef.current !== selectedConfig;
prevConfigRef.current = selectedConfig;
// Skip full reload if returning to same config (page re-mount)
if (!configChanged && selectedCompartment && compartmentTree.length > 0) return;
let cancelled = false; let cancelled = false;
setTreeLoading(true); setTreeLoading(true);
setCompartmentTree([]); setCompartmentTree([]);
if (configChanged) {
setSelectedCompartment(''); setSelectedCompartment('');
setCounts({}); setCounts({});
setResourceData(null); setResourceData(null);
}
(async () => { (async () => {
try { try {
@@ -939,17 +949,19 @@ export default function ExplorerPage() {
if (Array.isArray(flat)) { if (Array.isArray(flat)) {
const tree = buildTree(flat); const tree = buildTree(flat);
setCompartmentTree(tree); setCompartmentTree(tree);
// Auto-select first node and expand it // Auto-select first node only if not already selected
if (tree.length > 0) { if (tree.length > 0 && !selectedCompartment) {
setSelectedCompartment(tree[0].id); setSelectedCompartment(tree[0].id);
setTreeOpen((prev) => ({ ...prev, [tree[0].id]: true })); setTreeOpen((prev) => ({ ...prev, [tree[0].id]: true }));
} }
} }
if (Array.isArray(regs)) { if (Array.isArray(regs)) {
setAvailableRegions(regs); setAvailableRegions(regs);
if (selectedRegions.length === 0) {
const home = regs.find((r) => r.is_home); const home = regs.find((r) => r.is_home);
setSelectedRegions(home ? [home.name] : regs.length > 0 ? [regs[0].name] : []); setSelectedRegions(home ? [home.name] : regs.length > 0 ? [regs[0].name] : []);
} }
}
} catch { } catch {
/* ignore */ /* ignore */
} finally { } finally {

View File

@@ -1,30 +1,31 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { Terminal, Loader2, ChevronDown } from 'lucide-react'; import { Terminal, Loader2, ChevronDown, HelpCircle, X } from 'lucide-react';
import client from '@/api/client'; import client from '@/api/client';
import { useI18n } from '@/i18n'; import { useI18n } from '@/i18n';
import { useTerminalStore } from '@/stores/terminal';
interface TermLine { interface OciCfg { id: string; tenancy_name: string; }
type: 'input' | 'output' | 'error' | 'info';
text: string;
}
interface OciCfg { const FONT = "'JetBrains Mono','Fira Code','Cascadia Code','SF Mono','Consolas','Liberation Mono',monospace";
id: string; const BG = '#0c0c0c';
tenancy_name: string; const FG = '#cccccc';
} const GREEN = '#16c60c';
const BLUE = '#3b78ff';
const RED = '#e74856';
const DIM = '#767676';
const YELLOW = '#f9f1a5';
export default function TerminalPage() { export default function TerminalPage() {
const { t } = useI18n(); const { t } = useI18n();
const { lines, selectedCfg, cmdHistory, addLine, clearLines, setSelectedCfg, setCmdHistory, addToHistory } = useTerminalStore();
const [configs, setConfigs] = useState<OciCfg[]>([]); const [configs, setConfigs] = useState<OciCfg[]>([]);
const [selectedCfg, setSelectedCfg] = useState('');
const [command, setCommand] = useState(''); const [command, setCommand] = useState('');
const [lines, setLines] = useState<TermLine[]>([]);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
const [histIdx, setHistIdx] = useState(-1); const [histIdx, setHistIdx] = useState(-1);
const [suggestions, setSuggestions] = useState<string[]>([]); const [suggestions, setSuggestions] = useState<string[]>([]);
const [tabCount, setTabCount] = useState(0); const [tabCount, setTabCount] = useState(0);
const outputRef = useRef<HTMLDivElement>(null); const [showHelp, setShowHelp] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
@@ -34,63 +35,67 @@ export default function TerminalPage() {
}).catch(() => {}); }).catch(() => {});
}, []); }, []);
useEffect(() => { const scrollToBottom = useCallback(() => {
if (outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight; requestAnimationFrame(() => {
}, [lines]); if (containerRef.current) containerRef.current.scrollTop = containerRef.current.scrollHeight;
});
}, []);
useEffect(scrollToBottom, [lines, running, suggestions, scrollToBottom]);
useEffect(() => { useEffect(() => {
if (selectedCfg) { if (selectedCfg && cmdHistory.length === 0) {
client.get(`/terminal/history?oci_config_id=${selectedCfg}&limit=100`).then((data: any) => { client.get(`/terminal/history?oci_config_id=${selectedCfg}&limit=100`).then((data: any) => {
setCmdHistory(data.map((h: any) => h.command).reverse()); setCmdHistory(data.map((h: any) => h.command).reverse());
}).catch(() => {}); }).catch(() => {});
} }
}, [selectedCfg]); }, [selectedCfg]);
const addLine = useCallback((type: TermLine['type'], text: string) => {
setLines(prev => [...prev, { type, text }]); const cfgName = configs.find(c => c.id === selectedCfg)?.tenancy_name || 'oci';
}, []); const PS1_user = `agent@${cfgName}`;
const execute = useCallback(async () => { const execute = useCallback(async () => {
const cmd = command.trim(); const cmd = command.trim();
if (!cmd) return; if (!cmd) return;
if (!selectedCfg) { addLine('error', t('term.selectConfig')); return; } if (!selectedCfg) { addLine('error', t('term.selectConfig')); return; }
if (cmd === 'clear') { clearLines(); setCommand(''); return; }
// Built-in commands
if (cmd === 'clear') { setLines([]); setCommand(''); return; }
if (cmd === 'history') { if (cmd === 'history') {
addLine('input', `$ ${cmd}`); addLine('input', cmd);
cmdHistory.forEach((h, i) => addLine('output', ` ${i + 1} ${h}`)); cmdHistory.forEach((h, i) => addLine('output', ` ${String(i + 1).padStart(4)} ${h}`));
setCommand(''); setCommand(''); return;
return;
} }
if (cmd === 'help') { if (cmd === 'help') {
addLine('input', `$ ${cmd}`); addLine('input', cmd);
addLine('info', 'OCI CLI Terminal — Comandos disponíveis:'); addLine('info', 'Commands:');
addLine('info', ' oci <service> <resource> <action> [options] Executar comando OCI CLI'); addLine('info', ' oci <service> <resource> <action> [opts] Run OCI CLI command');
addLine('info', ' ocid1.instance.oc1.xxx... Auto-lookup resource by OCID');
addLine('info', ' find <name> Search resource by display name');
addLine('info', ' find %partial% Search with partial match');
addLine('info', ' find 10.0.1.5 Search by private IP');
addLine('info', ' clear Clear screen');
addLine('info', ' history Command history');
addLine('info', ' help This message');
addLine('info', '');
addLine('info', 'Shortcuts:');
addLine('info', ' Tab Autocomplete'); addLine('info', ' Tab Autocomplete');
addLine('info', ' ↑ / ↓ Navegar histórico'); addLine('info', ' ↑ / ↓ Browse history');
addLine('info', ' clear Limpar terminal'); addLine('info', ' Ctrl+L Clear screen');
addLine('info', ' history Ver histórico'); setCommand(''); return;
addLine('info', ' help Este menu');
setCommand('');
return;
} }
addLine('input', `$ ${cmd}`); addLine('input', cmd);
setCommand(''); setCommand('');
setCmdHistory(prev => [...prev, cmd]); addToHistory(cmd);
setHistIdx(-1); setHistIdx(-1);
setSuggestions([]); setSuggestions([]);
setRunning(true); setRunning(true);
try { try {
const resp = await client.post('/terminal/execute', { const resp = await client.post('/terminal/execute', { oci_config_id: selectedCfg, command: cmd }) as any;
oci_config_id: selectedCfg, if (resp.resolved_cmd) addLine('info', `${resp.resolved_cmd}`);
command: cmd,
}) as any;
if (resp.output) addLine('output', resp.output); if (resp.output) addLine('output', resp.output);
if (resp.error) addLine('error', resp.error); if (resp.error) addLine('error', resp.error);
if (!resp.output && !resp.error) addLine('info', '(no output)');
} catch (err: any) { } catch (err: any) {
addLine('error', err?.message || err?.detail || String(err)); addLine('error', err?.message || err?.detail || String(err));
} finally { } finally {
@@ -106,230 +111,215 @@ export default function TerminalPage() {
const sugs: string[] = resp.suggestions || []; const sugs: string[] = resp.suggestions || [];
if (sugs.length === 0) return; if (sugs.length === 0) return;
if (sugs.length === 1) { if (sugs.length === 1) {
// Single match — auto-complete
const parts = command.trimEnd().split(' '); const parts = command.trimEnd().split(' ');
if (command.endsWith(' ')) { if (command.endsWith(' ')) setCommand(command + sugs[0] + ' ');
setCommand(command + sugs[0] + ' '); else { parts[parts.length - 1] = sugs[0]; setCommand(parts.join(' ') + ' '); }
setSuggestions([]); setTabCount(0);
} else { } else {
parts[parts.length - 1] = sugs[0]; if (tabCount > 0) { addLine('input', command); addLine('info', sugs.join(' ')); }
setCommand(parts.join(' ') + ' '); setSuggestions(sugs); setTabCount(prev => prev + 1);
}
setSuggestions([]);
setTabCount(0);
} else {
// Multiple matches — show them
if (tabCount > 0) {
// Second Tab — display all
addLine('input', `$ ${command}`);
addLine('info', sugs.join(' '));
}
setSuggestions(sugs);
setTabCount(prev => prev + 1);
// Find common prefix
let common = sugs[0]; let common = sugs[0];
for (const s of sugs) { for (const s of sugs) { while (!s.startsWith(common)) common = common.slice(0, -1); }
while (!s.startsWith(common)) common = common.slice(0, -1);
}
if (common) { if (common) {
const parts = command.trimEnd().split(' '); const parts = command.trimEnd().split(' ');
const last = parts[parts.length - 1]; if (command.endsWith(' ')) { if (common.length > 0) setCommand(command + common); }
if (command.endsWith(' ')) { else if (common.length > (parts[parts.length - 1] || '').length) {
if (common.length > 0) setCommand(command + common); parts[parts.length - 1] = common; setCommand(parts.join(' '));
} else if (common.length > last.length) {
parts[parts.length - 1] = common;
setCommand(parts.join(' '));
} }
} }
} }
} catch { } catch { /* ignore */ }
// ignore
}
}, [command, running, tabCount, addLine]); }, [command, running, tabCount, addLine]);
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Tab') { if (e.key === 'Tab') { e.preventDefault(); handleTab(); return; }
e.preventDefault(); setTabCount(0); setSuggestions([]);
handleTab(); if (e.key === 'Enter' && !running) execute();
return; else if (e.key === 'ArrowUp') {
}
setTabCount(0);
setSuggestions([]);
if (e.key === 'Enter' && !running) {
execute();
} else if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
if (cmdHistory.length > 0) { if (cmdHistory.length > 0) {
const newIdx = histIdx < cmdHistory.length - 1 ? histIdx + 1 : histIdx; const n = histIdx < cmdHistory.length - 1 ? histIdx + 1 : histIdx;
setHistIdx(newIdx); setHistIdx(n); setCommand(cmdHistory[cmdHistory.length - 1 - n] || '');
setCommand(cmdHistory[cmdHistory.length - 1 - newIdx] || '');
} }
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
if (histIdx > 0) { if (histIdx > 0) { setHistIdx(histIdx - 1); setCommand(cmdHistory[cmdHistory.length - histIdx] || ''); }
setHistIdx(histIdx - 1); else { setHistIdx(-1); setCommand(''); }
setCommand(cmdHistory[cmdHistory.length - histIdx] || ''); } else if (e.key === 'l' && e.ctrlKey) { e.preventDefault(); clearLines(); }
} else {
setHistIdx(-1);
setCommand('');
}
} else if (e.key === 'l' && e.ctrlKey) {
e.preventDefault();
setLines([]);
} else if (e.key === 'c' && e.ctrlKey && running) {
addLine('error', '^C');
}
}; };
const cfgName = configs.find(c => c.id === selectedCfg)?.tenancy_name || ''; const Prompt = () => (
const prompt = `agent@${cfgName || 'oci'}:~$ `; <span>
<span style={{ color: GREEN, fontWeight: 700 }}>{PS1_user}</span>
<span style={{ color: DIM }}>:</span>
<span style={{ color: BLUE, fontWeight: 700 }}>~</span>
<span style={{ color: FG }}>$ </span>
</span>
);
return ( return (
<div className="page" style={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 0 }}>
{/* Header bar */} {/* Top bar — config selector */}
<div className="flex items-center gap-3" style={{ flexShrink: 0 }}> <div className="flex items-center gap-3 px-3 py-2" style={{ flexShrink: 0, background: 'var(--bg1)', borderBottom: '1px solid var(--bd)' }}>
<div className="flex items-center gap-2 px-3 py-2 rounded-lg" style={{ background: 'var(--bg2)', border: '1px solid var(--bd)' }}> <Terminal size={15} style={{ color: 'var(--ac)' }} />
<Terminal size={16} style={{ color: 'var(--ac)' }} /> <span className="text-xs font-bold" style={{ color: 'var(--t1)' }}>OCI CLI</span>
<span className="text-sm font-semibold" style={{ color: 'var(--t1)' }}>OCI CLI</span>
</div>
<div className="relative"> <div className="relative">
<select <select value={selectedCfg} onChange={e => { setSelectedCfg(e.target.value); clearLines(); setCmdHistory([]); }}
value={selectedCfg} className="pl-2.5 pr-7 py-1.5 rounded text-xs font-medium outline-none cursor-pointer appearance-none"
onChange={e => { setSelectedCfg(e.target.value); setLines([]); }} style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 180 }}>
className="px-3 py-2 pr-8 rounded-lg text-sm font-medium outline-none cursor-pointer appearance-none"
style={{ background: 'var(--bg2)', border: '1px solid var(--bd)', color: 'var(--t1)', minWidth: 200 }}
>
<option value="">-- {t('term.selectConfig')} --</option> <option value="">-- {t('term.selectConfig')} --</option>
{configs.map(c => ( {configs.map(c => <option key={c.id} value={c.id}>{c.tenancy_name}</option>)}
<option key={c.id} value={c.id}>{c.tenancy_name}</option>
))}
</select> </select>
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--t3)' }} /> <ChevronDown size={12} className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--t3)' }} />
</div> </div>
<div className="spacer" /> <div style={{ flex: 1 }} />
<span className="text-[.65rem]" style={{ color: 'var(--t4)', fontFamily: 'monospace' }}> <button
Tab: autocomplete | : history | Ctrl+L: clear onClick={() => setShowHelp(!showHelp)}
</span> className="flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-medium transition-all"
</div>
{/* Terminal window */}
<div
className="flex-1 flex flex-col rounded-lg overflow-hidden"
style={{ style={{
background: '#0d1117', background: showHelp ? 'var(--acl)' : 'var(--bg2)',
border: '1px solid #30363d', border: `1px solid ${showHelp ? 'var(--ac)' : 'var(--bd)'}`,
minHeight: 400, color: showHelp ? 'var(--ac)' : 'var(--t3)',
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Consolas', monospace", cursor: 'pointer',
fontSize: '0.8rem',
lineHeight: 1.5,
}} }}
onClick={() => inputRef.current?.focus()}
> >
{/* Title bar */} <HelpCircle size={13} />
<div className="flex items-center gap-2 px-3 py-1.5" style={{ background: '#161b22', borderBottom: '1px solid #30363d' }}> Help
<div className="flex gap-1.5"> </button>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: '#f85149' }} />
<div style={{ width: 10, height: 10, borderRadius: '50%', background: '#d29922' }} />
<div style={{ width: 10, height: 10, borderRadius: '50%', background: '#3fb950' }} />
</div>
<span style={{ color: '#8b949e', fontSize: '0.7rem', flex: 1, textAlign: 'center' }}>
agent@oci-cis-agent {cfgName || 'terminal'}
</span>
</div> </div>
{/* Output */} {/* Help panel */}
<div ref={outputRef} className="flex-1 overflow-y-auto px-3 py-2" style={{ scrollbarWidth: 'thin', scrollbarColor: '#30363d #0d1117' }}> {showHelp && (
<div style={{ flexShrink: 0, background: 'var(--bg2)', borderBottom: '1px solid var(--bd)', padding: '12px 16px', fontSize: '0.75rem' }}>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold" style={{ color: 'var(--t1)' }}>Terminal Commands</span>
<button onClick={() => setShowHelp(false)} style={{ color: 'var(--t4)', cursor: 'pointer', background: 'none', border: 'none' }}><X size={14} /></button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 24px', color: 'var(--t2)' }}>
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>oci &lt;service&gt; &lt;resource&gt; &lt;action&gt;</code><span style={{ color: 'var(--t4)' }}>Execute OCI CLI command</span></div>
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>oci</code><span style={{ color: 'var(--t4)' }}>Show OCI CLI help</span></div>
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>ocid1.instance.oc1.xxx...</code><span style={{ color: 'var(--t4)' }}>Auto-lookup resource by OCID</span></div>
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>find &lt;name&gt;</code><span style={{ color: 'var(--t4)' }}>Search resource by display name</span></div>
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>find %partial%</code><span style={{ color: 'var(--t4)' }}>Search with partial match (LIKE)</span></div>
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>find 10.0.1.5</code><span style={{ color: 'var(--t4)' }}>Search by private IP address</span></div>
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>clear</code><span style={{ color: 'var(--t4)' }}>Clear screen</span></div>
<div className="flex gap-2"><code style={{ color: 'var(--ac)', fontFamily: FONT, fontSize: '0.7rem', minWidth: 220 }}>history</code><span style={{ color: 'var(--t4)' }}>Show command history</span></div>
</div>
<div className="mt-2 pt-2" style={{ borderTop: '1px solid var(--bd)' }}>
<span className="text-xs font-bold" style={{ color: 'var(--t1)' }}>Shortcuts</span>
<div className="flex gap-6 mt-1" style={{ color: 'var(--t4)' }}>
<span><kbd style={{ background: 'var(--bg3)', padding: '1px 5px', borderRadius: 3, fontSize: '0.65rem', fontFamily: FONT }}>Tab</kbd> Autocomplete</span>
<span><kbd style={{ background: 'var(--bg3)', padding: '1px 5px', borderRadius: 3, fontSize: '0.65rem', fontFamily: FONT }}></kbd> History</span>
<span><kbd style={{ background: 'var(--bg3)', padding: '1px 5px', borderRadius: 3, fontSize: '0.65rem', fontFamily: FONT }}>Ctrl+L</kbd> Clear</span>
</div>
</div>
</div>
)}
{/* Terminal body — single continuous scroll */}
<div
ref={containerRef}
onClick={() => inputRef.current?.focus()}
style={{
flex: 1,
overflow: 'auto',
background: BG,
padding: '8px 12px',
fontFamily: FONT,
fontSize: '13px',
lineHeight: '20px',
color: FG,
cursor: 'text',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
scrollbarWidth: 'thin',
scrollbarColor: `#333 ${BG}`,
}}
>
{/* MOTD */} {/* MOTD */}
{lines.length === 0 && ( {lines.length === 0 && !running && (
<div style={{ color: '#3fb950' }}> <>
<pre style={{ margin: 0, fontSize: '0.7rem', color: '#238636' }}>{` <span style={{ color: GREEN }}>Oracle Cloud Infrastructure CLI</span>{'\n'}
___ ____ ___ ____ _ ___ <span style={{ color: DIM }}>Type </span>
/ _ \\ / ___|_ _| / ___| | |_ _| <span style={{ color: YELLOW }}>help</span>
| | | | | | | | | | | | | <span style={{ color: DIM }}> for commands, </span>
| |_| | |___ | | | |___| |___ | | <span style={{ color: YELLOW }}>Tab</span>
\\___/ \\____|___| \\____|_____|___| <span style={{ color: DIM }}> for autocomplete</span>{'\n\n'}
`}</pre> </>
<span style={{ color: '#8b949e' }}>Oracle Cloud Infrastructure CLI v3.x</span>
<br />
<span style={{ color: '#484f58' }}>Type <span style={{ color: '#58a6ff' }}>help</span> for available commands, <span style={{ color: '#58a6ff' }}>Tab</span> for autocomplete</span>
<br /><br />
</div>
)} )}
{lines.map((line, i) => ( {/* All output lines — continuous flow */}
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}> {lines.map((line, i) => {
{line.type === 'input' && ( if (line.type === 'input') {
<span><span style={{ color: '#3fb950' }}>{prompt.split(':')[0]}:</span><span style={{ color: '#58a6ff' }}>~</span><span style={{ color: '#e6edf3' }}>$ </span><span style={{ color: '#e6edf3' }}>{line.text.replace(/^\$ /, '')}</span></span> return <span key={i}><Prompt /><span style={{ color: FG }}>{line.text}</span>{'\n'}</span>;
)} }
{line.type === 'output' && ( if (line.type === 'error') {
<span style={{ color: '#c9d1d9' }}>{line.text}</span> return <span key={i} style={{ color: RED }}>{line.text}{'\n'}</span>;
)} }
{line.type === 'error' && ( if (line.type === 'info') {
<span style={{ color: '#f85149' }}>{line.text}</span> return <span key={i} style={{ color: DIM }}>{line.text}{'\n'}</span>;
)} }
{line.type === 'info' && ( // output
<span style={{ color: '#8b949e' }}>{line.text}</span> return <span key={i} style={{ color: FG }}>{line.text}{'\n'}</span>;
)} })}
</div>
))}
{running && ( {/* Autocomplete suggestions */}
<div className="flex items-center gap-2" style={{ color: '#58a6ff' }}>
<Loader2 size={12} className="animate-spin" />
<span style={{ fontSize: '0.75rem' }}>{t('term.executing')}</span>
</div>
)}
{/* Autocomplete suggestions inline */}
{suggestions.length > 1 && !running && ( {suggestions.length > 1 && !running && (
<div style={{ color: '#8b949e', margin: '2px 0' }}> <span style={{ color: DIM }}>{suggestions.join(' ')}{'\n'}</span>
{suggestions.join(' ')}
</div>
)} )}
</div>
{/* Input line */} {/* Running indicator */}
<div className="flex items-center px-3 py-1.5" style={{ borderTop: '1px solid #21262d', background: '#0d1117' }}> {running && (
<span style={{ color: '#3fb950', whiteSpace: 'nowrap' }}>{prompt.split(':')[0]}:</span> <span style={{ color: BLUE }}>
<span style={{ color: '#58a6ff' }}>~</span> <Loader2 size={12} className="animate-spin" style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6 }} />
<span style={{ color: '#e6edf3', marginRight: 4 }}>$ </span> {t('term.executing')}{'\n'}
</span>
)}
{/* Active input line — part of the scroll, not a separate bar */}
{!running && (
<span style={{ display: 'inline' }}>
<Prompt />
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={command} value={command}
onChange={e => { setCommand(e.target.value); setTabCount(0); setSuggestions([]); }} onChange={e => { setCommand(e.target.value); setTabCount(0); setSuggestions([]); }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
disabled={running}
placeholder={running ? '' : ''}
autoFocus autoFocus
spellCheck={false} spellCheck={false}
autoComplete="off" autoComplete="off"
autoCapitalize="off" autoCapitalize="off"
className="flex-1 outline-none"
style={{ style={{
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
color: '#e6edf3', outline: 'none',
color: FG,
fontSize: 'inherit', fontSize: 'inherit',
fontFamily: 'inherit', fontFamily: 'inherit',
lineHeight: 'inherit', lineHeight: 'inherit',
caretColor: '#e6edf3',
padding: 0, padding: 0,
margin: 0,
width: `${Math.max(1, command.length + 1)}ch`,
caretColor: FG,
verticalAlign: 'baseline',
}} }}
/> />
{/* Blinking cursor when empty and not running */}
{!command && !running && (
<span style={{ <span style={{
display: 'inline-block', width: 7, height: 14, display: 'inline-block',
background: '#e6edf3', marginLeft: -2, width: '8px',
animation: 'blink 1s step-end infinite', height: '15px',
background: FG,
verticalAlign: 'text-bottom',
animation: 'termBlink 1s step-end infinite',
marginLeft: command ? 0 : -1,
}} /> }} />
</span>
)} )}
</div> </div>
</div>
<style>{` <style>{`
@keyframes blink { @keyframes termBlink {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0; } 50% { opacity: 0; }
} }

View File

@@ -201,12 +201,18 @@ interface AppState {
expSelectedCategory: string; expSelectedCategory: string;
expSelectedResourceType: string; expSelectedResourceType: string;
expTreeWidth: number; expTreeWidth: number;
expCompartmentTree: any[];
expAvailableRegions: any[];
expCounts: Record<string, number>;
setExpSelectedConfig: (v: string) => void; setExpSelectedConfig: (v: string) => void;
setExpSelectedCompartment: (v: string) => void; setExpSelectedCompartment: (v: string) => void;
setExpSelectedRegions: (v: Updater<string[]>) => void; setExpSelectedRegions: (v: Updater<string[]>) => void;
setExpSelectedCategory: (v: string) => void; setExpSelectedCategory: (v: string) => void;
setExpSelectedResourceType: (v: string) => void; setExpSelectedResourceType: (v: string) => void;
setExpTreeWidth: (v: number) => void; setExpTreeWidth: (v: number) => void;
setExpCompartmentTree: (v: any[]) => void;
setExpAvailableRegions: (v: any[]) => void;
setExpCounts: (v: Record<string, number>) => void;
// ── Reports Page state ── // ── Reports Page state ──
rptOciVal: string; rptOciVal: string;
@@ -340,6 +346,12 @@ export const useAppStore = create<AppState>((set) => ({
setExpSelectedCategory: (v) => set({ expSelectedCategory: v }), setExpSelectedCategory: (v) => set({ expSelectedCategory: v }),
setExpSelectedResourceType: (v) => set({ expSelectedResourceType: v }), setExpSelectedResourceType: (v) => set({ expSelectedResourceType: v }),
setExpTreeWidth: (v) => set({ expTreeWidth: v }), setExpTreeWidth: (v) => set({ expTreeWidth: v }),
expCompartmentTree: [],
expAvailableRegions: [],
expCounts: {},
setExpCompartmentTree: (v) => set({ expCompartmentTree: v }),
setExpAvailableRegions: (v) => set({ expAvailableRegions: v }),
setExpCounts: (v) => set({ expCounts: v }),
// ── Reports Page ── // ── Reports Page ──
rptOciVal: '', rptOciVal: '',

View File

@@ -0,0 +1,30 @@
import { create } from 'zustand';
interface TermLine {
type: 'input' | 'output' | 'error' | 'info';
text: string;
}
interface TerminalState {
lines: TermLine[];
selectedCfg: string;
cmdHistory: string[];
addLine: (type: TermLine['type'], text: string) => void;
setLines: (lines: TermLine[]) => void;
clearLines: () => void;
setSelectedCfg: (cfg: string) => void;
setCmdHistory: (history: string[]) => void;
addToHistory: (cmd: string) => void;
}
export const useTerminalStore = create<TerminalState>((set) => ({
lines: [],
selectedCfg: '',
cmdHistory: [],
addLine: (type, text) => set((s) => ({ lines: [...s.lines, { type, text }] })),
setLines: (lines) => set({ lines }),
clearLines: () => set({ lines: [] }),
setSelectedCfg: (cfg) => set({ selectedCfg: cfg }),
setCmdHistory: (history) => set({ cmdHistory: history }),
addToHistory: (cmd) => set((s) => ({ cmdHistory: [...s.cmdHistory, cmd] })),
}));