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]}
# ── 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(
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
@@ -1111,7 +1198,32 @@ async def terminal_execute(req: dict, u=Depends(current_user)):
if not command:
raise HTTPException(400, "Comando vazio")
_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
if command == "oci":
command = "oci --help"
if not command.startswith("oci "):
raise HTTPException(400, "Apenas comandos 'oci' são permitidos. Ex: oci iam user list")
if _TERMINAL_BLOCKED_PATTERNS.search(command):
@@ -1141,6 +1253,7 @@ async def terminal_execute(req: dict, u=Depends(current_user)):
"exit_code": exit_code,
"output": output[:50000],
"error": error[:5000] if exit_code != 0 else "",
"resolved_cmd": resolved_cmd,
}
except asyncio.TimeoutError:
with db() as c:

View File

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

View File

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

View File

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