feat: state persistence — Terminal + Explorer survive navigation (Zustand stores)
This commit is contained in:
113
backend/app.py
113
backend/app.py
@@ -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:
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 <service> <resource> <action></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 <name></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; }
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
30
frontend-react/src/stores/terminal.ts
Normal file
30
frontend-react/src/stores/terminal.ts
Normal 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] })),
|
||||
}));
|
||||
Reference in New Issue
Block a user