Files
floci-ui/packages/frontend/src/components/LogsQueryPanel.tsx
T
Zakaria 91ce11274d
CI / checks (pull_request) Successful in 25m47s
chore: show TheCloud instead of Floci in console text and messages
Labels, empty states, error messages and defaults the console shows now name
TheCloud or its runtimes. Identifiers that must match the emulator (FLOCI_*
settings, the X-Floci-CSRF header, emulator-returned host names) are unchanged.
2026-10-24 14:30:44 -04:00

222 lines
9.0 KiB
TypeScript

import {useEffect, useState, type KeyboardEvent} from 'react'
import {createPortal} from 'react-dom'
import {Maximize2, Minimize2, Play, Terminal} from 'lucide-react'
import {useMutation} from '@tanstack/react-query'
import {queryLogs} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {LogsInsightsQueryResult} from '@/types/resource'
const DEFAULT_QUERY = 'fields @timestamp, @message\n| sort @timestamp desc\n| limit 20'
const RANGE_OPTIONS = [
{label: '15m', minutes: 15},
{label: '1h', minutes: 60},
{label: '6h', minutes: 360},
{label: '24h', minutes: 1440},
]
interface LogsQueryPanelProps {
cloud: CloudProvider
logGroupName?: string
runtimeReachable: boolean
autoRunKey?: number
}
/**
* Lives inside ResourceInspector next to a selected log group's metadata,
* rather than as a full-width section a dev has to scroll the resource table
* to find. The inspector column is narrow, so this expands into a full-page
* modal on demand via a portal — the same mounted component either way, so
* expanding/collapsing keeps the in-progress query text and results.
*
* Floci's supported StartQuery subset (fields/filter/sort/dedup/limit — see
* docs/services/cloudwatch.md) has no UI of its own; this is that surface.
* Styled after the real CloudWatch Logs Insights query editor (dark code box,
* time-range chips, run-with-shortcut), minus the capabilities Floci doesn't
* implement (stats/visualize, pattern analysis, live tail) — this repo's own
* rule against decorative, unwired controls.
*/
export function LogsQueryPanel({cloud, logGroupName, runtimeReachable, autoRunKey = 0}: LogsQueryPanelProps) {
const [queryText, setQueryText] = useState(DEFAULT_QUERY)
const [rangeMinutes, setRangeMinutes] = useState(RANGE_OPTIONS[1].minutes)
const [expanded, setExpanded] = useState(false)
const queryMut = useMutation({
mutationFn: async () => {
const endTime = Math.floor(Date.now() / 1000)
try {
return await queryLogs(cloud, logGroupName ?? '', {
queryString: queryText,
startTime: endTime - rangeMinutes * 60,
endTime,
})
} catch (error) {
if (isMissingLogGroup(error)) return {queryId: '', status: 'Complete', rows: []}
throw error
}
},
})
const {mutate: runQuery, reset: resetQuery} = queryMut
// ResourceInspector reuses this instance across row selections rather than
// remounting it, so without this the previous group's results linger and
// read as if they belong to the newly selected one.
useEffect(() => {
resetQuery()
setQueryText(DEFAULT_QUERY)
setRangeMinutes(RANGE_OPTIONS[1].minutes)
setExpanded(false)
}, [logGroupName, resetQuery])
useEffect(() => {
if (autoRunKey > 0 && logGroupName && runtimeReachable) runQuery()
}, [autoRunKey, logGroupName, runQuery, runtimeReachable])
if (!logGroupName) return null
function submit() {
if (!queryText.trim()) return
runQuery()
}
function onEditorKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault()
submit()
}
}
const panel = (
<section className="logs-query-panel">
<div className="logs-query-header">
<Terminal size={15}/>
<span>
<small>Insights query</small>
<strong>{logGroupName}</strong>
<em>fields / filter / sort / dedup / limit</em>
</span>
<div className="logs-query-range" role="group" aria-label="Time range">
{RANGE_OPTIONS.map((option) => (
<button
key={option.minutes}
type="button"
className={option.minutes === rangeMinutes ? 'active' : ''}
disabled={!runtimeReachable}
onClick={() => setRangeMinutes(option.minutes)}
>
{option.label}
</button>
))}
</div>
<button
className="icon-btn"
type="button"
title={expanded ? 'Collapse' : 'Expand to full page'}
onClick={() => setExpanded((value) => !value)}
>
{expanded ? <Minimize2 size={14}/> : <Maximize2 size={14}/>}
</button>
</div>
<div className="logs-query-editor-wrap">
<textarea
className="logs-query-textarea"
value={queryText}
onChange={(event) => setQueryText(event.target.value)}
onKeyDown={onEditorKeyDown}
spellCheck={false}
rows={expanded ? 8 : 3}
/>
<div className="logs-query-actions">
<span className="logs-query-shortcut">{macShortcut()}+Enter</span>
<button
className="button primary"
type="button"
disabled={!runtimeReachable || queryMut.isPending || !queryText.trim()}
onClick={submit}
>
<Play size={14}/>
{queryMut.isPending ? 'Running' : 'Run query'}
</button>
</div>
</div>
{queryMut.error instanceof Error && <div className="form-error">{queryMut.error.message}</div>}
<LogsQueryResults result={queryMut.data} expanded={expanded}/>
</section>
)
if (!expanded) return panel
return createPortal(
<div className="modal-overlay" onClick={() => setExpanded(false)}>
<div className="logs-query-modal" onClick={(event) => event.stopPropagation()}>
{panel}
</div>
</div>,
document.body,
)
}
function macShortcut(): string {
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘' : 'Ctrl'
}
function isMissingLogGroup(error: unknown): boolean {
return error instanceof Error && /log group does not exist|resource_not_found/i.test(error.message)
}
const STILL_RUNNING_STATUSES = new Set(['Running', 'Scheduled'])
function LogsQueryResults({result, expanded}: {result?: LogsInsightsQueryResult; expanded: boolean}) {
if (!result) {
return (
<div className="empty compact sql-results-empty">
<h3>No query results</h3>
<p>Run an Insights query to see matching log events.</p>
</div>
)
}
// Different log events discover different fields (a query with no `fields`
// clause, or events whose shape varies row to row), so a field present only
// on a later row must not be dropped just because row 0 lacked it.
const columns = Array.from(new Set(result.rows.flatMap((row) => Object.keys(row))))
const stillRunning = STILL_RUNNING_STATUSES.has(result.status)
return (
<div className="sql-results">
<div className="sql-results-summary">
<span>Status: {result.status}</span>
<span>{result.rows.length} rows</span>
</div>
{stillRunning && (
<div className="muted padded">
The runtime hadn't finished this query when the console stopped waiting — these rows are a partial,
in-progress result, not the full answer. Run the query again to see if it has finished
(query id: {result.queryId}).
</div>
)}
{!stillRunning && result.rows.length === 0 && <div className="muted padded">No events matched this query.</div>}
{columns.length > 0 && (
<div className={expanded ? 'sql-result-table-wrap logs-query-table-wrap--expanded' : 'sql-result-table-wrap'}>
<table className="table sql-result-table">
<thead>
<tr>
{columns.map((column) => <th key={column}>{column}</th>)}
</tr>
</thead>
<tbody>
{result.rows.map((row, index) => (
<tr key={index}>
{columns.map((column) => (
<td key={column} title={row[column]}>{row[column]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}