diff --git a/ivette/src/dome/misc/format.ts b/ivette/src/dome/misc/format.ts index feee25f0517b547b1f3834cd771037521594f1c4..fa9ff623f47a21324cfd8b0f17a1c663b1907b06 100644 --- a/ivette/src/dome/misc/format.ts +++ b/ivette/src/dome/misc/format.ts @@ -33,9 +33,12 @@ Formats a duration, specified in seconds, into hour, minutes, seconds, milliseconds or nanoseconds, depending on range. + Negative or null durations are reported by `'0'`. + For instance, returns `'250ms'` for an input time of `.25`. */ export function duration(time : number) : string { + if (time <= 0.0) return '0'; if (time < 1.0e-3) return `${Math.round(time * 1.0e6)}µs`; if (time < 1.0) return `${Math.round(time * 1.0e3)}ms`; if (time < 60) return `${Math.round(time)}s`; diff --git a/ivette/src/frama-c/index.tsx b/ivette/src/frama-c/index.tsx index 5956727d634e3bf4d7f247977770acbdc916053c..abc2b6bc1349f6a74822a63749fb22e40f2267c3 100644 --- a/ivette/src/frama-c/index.tsx +++ b/ivette/src/frama-c/index.tsx @@ -35,6 +35,7 @@ import SourceCode from 'frama-c/kernel/SourceCode'; import PivotTable from 'frama-c/kernel/PivotTable'; import Locations from 'frama-c/kernel/Locations'; import Properties from 'frama-c/kernel/Properties'; +import { RecordingLogs, ServerLogs } from 'frama-c/kernel/ServerLogs'; import 'frama-c/kernel/style.css'; @@ -56,7 +57,15 @@ Ivette.registerSidebar({ </> }); -Ivette.registerToolbar({ id: 'ivette.history', children: <History /> }); +Ivette.registerToolbar({ + id: 'ivette.history', + children: <History /> +}); + +Ivette.registerStatusbar({ + id: 'fc.kernel.recordinglogs', + children: <RecordingLogs /> +}); /* -------------------------------------------------------------------------- */ /* --- Frama-C Kernel Groups --- */ @@ -67,6 +76,13 @@ Ivette.registerGroup({ label: 'Frama-C', }); +Ivette.registerComponent({ + id: 'fc.kernel.serverlogs', + label: 'Server Logs', + title: 'Frama-C server output logs', + children: <ServerLogs /> +}); + Ivette.registerComponent({ id: 'fc.kernel.astinfo', label: 'Inspector', diff --git a/ivette/src/frama-c/kernel/ServerLogs.tsx b/ivette/src/frama-c/kernel/ServerLogs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e62ff6098d6a46c5213387d9039bf9457e158f7c --- /dev/null +++ b/ivette/src/frama-c/kernel/ServerLogs.tsx @@ -0,0 +1,601 @@ +/* ************************************************************************ */ +/* */ +/* This file is part of Frama-C. */ +/* */ +/* Copyright (C) 2007-2023 */ +/* CEA (Commissariat à l'énergie atomique et aux énergies */ +/* alternatives) */ +/* */ +/* you can redistribute it and/or modify it under the terms of the GNU */ +/* Lesser General Public License as published by the Free Software */ +/* Foundation, version 2.1. */ +/* */ +/* It is distributed in the hope that it will be useful, */ +/* but WITHOUT ANY WARRANTY; without even the implied warranty of */ +/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the */ +/* GNU Lesser General Public License for more details. */ +/* */ +/* See the GNU Lesser General Public License version 2.1 */ +/* for more details (enclosed in the file licenses/LGPLv2.1). */ +/* */ +/* ************************************************************************ */ + +// -------------------------------------------------------------------------- +// --- Display logs emitted by Server +// -------------------------------------------------------------------------- + +import * as React from 'react'; + +import * as Display from 'ivette/display'; +import { IconButton } from 'dome/controls/buttons'; +import { Cell } from 'dome/controls/labels'; +import { Page } from 'dome/text/pages'; +import * as Editor from 'dome/text/editor'; +import * as Arrays from 'dome/table/arrays'; +import { Table, Column, Renderer } from 'dome/table/views'; +import { BSplit } from 'dome/layout/splitters'; +import { Hbox, Scroll, Vbox } from 'dome/layout/boxes'; +import { GlobalState, useGlobalState } from 'dome/data/states'; +import { json } from 'dome/data/json'; +import * as Compare from 'dome/data/compare'; +import { duration } from 'dome/misc/format'; + +import { RqKind } from 'frama-c/server'; + +// -------------------------------------------------------------------------- +// --- Logs types +// -------------------------------------------------------------------------- + +export enum typeLog { + /** Signal emitted by server. */ + SIGNAL = 'SIGNAL', + /** Signal sent to the server. */ + REQUEST = 'REQUEST', + /** Message information. */ + FEEDBACK = 'FEEDBACK', +} + +export enum status { + /** Request is waiting to be resolved. */ + PENDING = 'PENDING', + /** Request is resolved. */ + RESOLVED = 'RESOLVED', + /** Request is rejected. */ + REJECTED = 'REJECTED', + /** Request is in error. */ + ERROR = 'ERROR', + /** New signal ON. */ + SIGON = 'SIGON', + /** Signal is OFF. */ + SIGOFF = 'SIGOFF' +} + +enum LogFlow { + /** Pending request, Signal ON or Feedback. */ + IN = 'IN', + /** Response of a specific Request or Signal. */ + OUT = 'OUT', +} + +/** Server log type. */ +interface Log { + /** Row identifier. */ + key: number; + /** The precise time of log registration. */ + timestamp: number; + /** Gap time between a selected log and the other logs. */ + delta?: number; + /** Type of log. */ + type: typeLog; + /** The log name. */ + name: string; + /** Id of request. */ + rid?: string; + /** The request kind (GET, SET, EXEC). */ + kind?: RqKind; + /** Log status */ + status?: status; + /** The parameters or data stored in request. */ + params?: json; +} + +interface RequestInfo { + rid: string; + kind?: RqKind; + name?: string; + param?: json; + statut: status; +} + +// -------------------------------------------------------------------------- +// --- Server Logs Compact Model +// -------------------------------------------------------------------------- + +export const RECORDING = new GlobalState<boolean>(false); + +class LogModel extends Arrays.CompactModel<number, Log> { + private logs: Log[] = []; + private logKey = 0; + private recording = false; + + constructor() { + super((log: Log) => log.key); + RECORDING.on((v) => { + if (v && !this.recording) this.clear(); + this.recording = v; + }); + } + + /** Empty Compact Model and reset key values. */ + clear(): void { + this.logKey = 0; + super.clear(); + } + + /** Register a new log in Server Logs Table. */ + addLog(log: Log): void { + if (this.recording) { + this.logs.push(log); + this.flushLogs(); + } + } + + /** Find the corresponding request with it's id. */ + findNameByRid(rid: string): string { + const data = this.getArray().concat(this.logs); + const log = data.filter(log => log.rid === rid); + const lastLog = log.pop(); + return lastLog ? lastLog.name : ''; + } + + /** Register a new request log. */ + registerRequest(request: RequestInfo): void { + if (!this.recording) return; + const { rid, kind, name, param, statut } = request; + const log: Log = { + key: this.logKey++, + rid, + timestamp: Date.now(), + delta: 0, + type: typeLog.REQUEST, + kind: kind, + name: name ?? this.findNameByRid(rid), + params: param, + status: statut + }; + this.addLog(log); + } + + /** Register a new signal log. */ + registerSignal(id: string, statut: status): void { + if (!this.recording) return; + const log: Log = { + key: this.logKey++, + timestamp: Date.now(), + delta: 0, + type: typeLog.SIGNAL, + name: id, + status: statut + }; + this.addLog(log); + } + + /** Register a new feedback log. */ + registerFeedback(msg: string): void { + if (!this.recording) return; + const log: Log = { + key: this.logKey++, + timestamp: Date.now(), + delta: 0, + type: typeLog.FEEDBACK, + name: msg + }; + this.addLog(log); + } + + /** Update Compact Model with current logs. */ + flushLogs(): void { + if (this.logs.length > 0) { + this.updateData(this.logs); + this.reload(); + this.logs = []; + } + } + +} + +/** Main log compact model to register new logs emitted by the server*/ +export const logModel: LogModel = new LogModel(); + +// ------------------------------------------------------------------------- +// --- Table columns +// ------------------------------------------------------------------------- + +const renderCell: Renderer<string> = + (text: string): JSX.Element => (<Cell title={text}> {text} </Cell>); +const renderType: Renderer<typeLog> = (t: typeLog): JSX.Element => { + switch (t) { + case typeLog.FEEDBACK: + return <Cell title={'Feedback'}>{'INFO'}</Cell>; + case typeLog.REQUEST: + return <Cell title={'Request'}>{'RQ'}</Cell>; + case typeLog.SIGNAL: + return <Cell title={'Signal'}>{'SIG'}</Cell>; + } +}; + +const renderTime: Renderer<string> = (date: string): JSX.Element => { + const d = new Date(date); + const h = d.getHours().toString().padStart(2, '0'); + const mn = d.getMinutes().toString().padStart(2, '0'); + const s = d.getSeconds().toString().padStart(2, '0'); + const ms = d.getMilliseconds().toString().padStart(3, '0'); + const newTime = `${h}:${mn}:${s}.${ms}`; + return <Cell title={newTime}> {newTime} </Cell>; +}; + +const renderDelta = (delta: number): JSX.Element => { + const text = + delta > 0 ? `+${duration(delta)}` : + delta < 0 ? `-${duration(-delta)}` : + '0'; + return <Cell>{text}</Cell>; +}; + +const LogColumns = (): JSX.Element => ( + <> + <Column + id="key" + label="#" + title="Line number" + visible={false} + width={27} + render={renderCell} + /> + <Column + id="timestamp" + label="Timestamp" + width={90} + title="Registration time" + render={renderTime} + /> + <Column + id="delta" + label="Delta time" + align='right' + width={60} + title="Gap time between a selected log and other logs" + render={renderDelta} + /> + <Column + id="type" + label="Type" + title="Log type (Feedback, Request, Signal)" + width={50} + align='center' + render={renderType} + /> + <Column + id="rid" + label="RQ ID" + title="Request ID" + width={55} + align='center' + render={renderCell} + /> + <Column + id="kind" + label="Kind" + title="Request kind (GET, SET, EXEC)" + width={50} + align='center' + visible={false} + render={renderCell} + /> + <Column + id="status" + label="Status" + title="Request status" + width={80} + align='center' + render={renderCell} + /> + <Column + id="name" + label="Name" + title="Log name" + fill + render={renderCell} + /> + </> +); + +// ------------------------------------------------------------------------- +// --- Displays related log +// ------------------------------------------------------------------------- + +/** Find request log. */ +const findRequestLog = (log: Log, logs: Log[]): Log | undefined => { + if (log.status === status.PENDING) { + return logs.find( + (l) => + l.type === typeLog.REQUEST && + l.status !== status.PENDING && + l.rid === log.rid && + l.name === log.name && + l.key > log.key + ); + } else { + return logs + .filter( + (l) => + l.type === typeLog.REQUEST && + l.status === status.PENDING && + l.rid === log.rid && + l.name === log.name && + l.key < log.key + ) + .pop(); + } +}; + +/** Find signal log. */ +const findSignalLog = (log: Log, logs: Log[]): Log | undefined => { + if (log.status === status.SIGON) { + return logs.find( + (l) => + l.type === typeLog.SIGNAL && + l.status === status.SIGOFF && + l.name === log.name && + l.key > log.key + ); + } else { + return logs + .filter( + (l) => + l.type === typeLog.SIGNAL && + l.status === status.SIGON && + l.name === log.name && + l.key < log.key + ) + .pop(); + } +}; + +/** Find corresponding log if it exists. */ +const findLog = (log?: Log): Log | undefined => { + if (!log) return; + + const logs = logModel.getArray(); + + switch (log.type) { + case typeLog.REQUEST: + return findRequestLog(log, logs); + case typeLog.SIGNAL: + return findSignalLog(log, logs); + default: + return undefined; + } +}; + +// ------------------------------------------------------------------------- +// --- Request Data or Parameters in Editor +// ------------------------------------------------------------------------- + +const extensions: Editor.Extension[] = [ + Editor.ReadOnly, + Editor.Selection, + Editor.LineNumbers, + Editor.LanguageHighlighter, + Editor.HighlightActiveLine, +]; + +/** Displays a simple view of the request data or parameters. */ +function ParamViewer({ log }: { log: Log }): JSX.Element { + const { view, Component } = Editor.Editor(extensions); + const [_content, setContent] = React.useState<string>(); + + React.useEffect(() => { + const newContent = JSON.stringify(log.params, null, 2); + setContent(newContent); + const Source = Editor.createTextField<string>('', (s: string) => s); + Source.set(view, newContent); + }, [view, log]); + + return <Component />; +} + +// ------------------------------------------------------------------------- +// --- IN & OUT Navigation Tabs +// ------------------------------------------------------------------------- + +const getLogType = (log?: Log): LogFlow => { + if (!log || log.type === typeLog.FEEDBACK) + return LogFlow.IN; + + switch (log.status) { + case status.PENDING: + return LogFlow.IN; + case status.SIGON: + return LogFlow.IN; + default: + return LogFlow.OUT; + } +}; + +interface TabProps { + label: string; + active: boolean; + onClick: () => void; +} + +function Tab({ label, active, onClick }: TabProps): JSX.Element { + return ( + <div + className={`server-logs-tab ${active && 'server-logs-tab-active'}`} + onClick={onClick} + > + {label} + </div> + ); +} + +interface InOutTabsProps { + log?: Log; + relatedLog?: Log; + selectLog: (s?: Log) => void; +} + +function InOutTabs(props: InOutTabsProps): JSX.Element { + const { log, relatedLog, selectLog } = props; + + const type = getLogType(log); + const activeTab: LogFlow = type; + + const handleClick = (flow: LogFlow): void => { + if (flow !== activeTab) selectLog(relatedLog); + }; + + return relatedLog ? ( + <> + <Tab + label='IN' + active={activeTab === LogFlow.IN} + onClick={() => handleClick(LogFlow.IN)} + /> + <Tab + label='OUT' + active={activeTab === LogFlow.OUT} + onClick={() => handleClick(LogFlow.OUT)} + /> + </> + ) : ( + <Tab + label={type} + active={activeTab === type} + onClick={() => handleClick(type)} + /> + ); +} + +// ------------------------------------------------------------------------- +// --- Bottom Panel +// ------------------------------------------------------------------------- +interface LogPanelProps { + selectedLog?: Log; + selectLog: (s?: Log) => void; +} + +function LogPanel({ selectedLog, selectLog }: LogPanelProps): JSX.Element { + + const relatedLog = findLog(selectedLog); + + return ( + <Vbox style={{ height: '100%' }}> + <Hbox style={{ alignItems: 'center' }}> + <InOutTabs + log={selectedLog} + relatedLog={relatedLog} + selectLog={selectLog} + /> + <IconButton + style={{ marginLeft: '5px' }} + icon="CROSS" + title="Close" + size={14} + onClick={() => selectLog(undefined)} + /> + </Hbox> + {selectedLog?.type === typeLog.REQUEST ? ( + <ParamViewer log={selectedLog} /> + ) : ( + <Scroll> + <Page className="message-page"> + {`${selectedLog?.type} : ${selectedLog?.name}`} + </Page> + </Scroll> + )} + </Vbox> + ); +} + +/** Calculate the time delta between a specific log and the other logs. */ +function computeDelta(log?: Log): void { + if (!log || !log.kind) return; + const logs = logModel.getArray(); + const newLogs = logs.map(l => { + const diff = l.timestamp - log.timestamp; + const delta = diff / 1000; + return { ...l, delta }; + }); + logModel.replaceAllDataWith(newLogs); +} + +// ------------------------------------------------------------------------- +// --- Logs Recording Button +// ------------------------------------------------------------------------- + +export function RecordingLogs(): JSX.Element { + const { active } = Display.useComponentStatus('fc.kernel.serverlogs'); + const [isRecording] = useGlobalState(RECORDING); + const onClick = (): void => RECORDING.setValue(!isRecording); + return ( + <IconButton + kind={isRecording ? 'negative' : 'warning'} + icon={isRecording ? 'MEDIA.STOP' : 'MEDIA.PAUSE'} + display={active} + onClick={onClick} + title='Stop recording logs' + /> + ); +} + +// ------------------------------------------------------------------------- +// --- Server Logs Table +// ------------------------------------------------------------------------- + +const byLog: Compare.ByFields<Log> = { + key: Compare.number, + timestamp: Compare.defined(Compare.number), + type: Compare.string, + rid: Compare.defined(Compare.string), + kind: Compare.defined(Compare.string), + status: Compare.defined(Compare.byEnum(status)) +}; + +export function ServerLogs(): JSX.Element { + const [recording] = useGlobalState(RECORDING); + + const [model] = React.useState(() => { + const m = logModel; + m.setOrderingByFields(byLog); + return m; + }); + const [selectedLog, selectLog] = React.useState<Log>(); + React.useEffect(() => model.flushLogs(), [model, recording]); + React.useEffect(() => computeDelta(selectedLog), [selectedLog]); + + return ( + <BSplit + settings="ivette.serverlogs.filterSplit" + defaultPosition={210} + unfold={selectedLog !== undefined} + > + <Table<number, Log> + model={logModel} + sorting={logModel} + selection={selectedLog?.key} + onSelection={selectLog} + settings="ivette.serverlogs.table" + > + <LogColumns /> + </Table> + <LogPanel + selectedLog={selectedLog} + selectLog={selectLog} + /> + </BSplit> + ); +} + +// -------------------------------------------------------------------------- diff --git a/ivette/src/frama-c/kernel/style.css b/ivette/src/frama-c/kernel/style.css index 8ec00c315d5553426a60ec12c5a64dfd2d7bcec2..4c50089c1686e58e3c23ff01fe4f9b70f7b82420 100644 --- a/ivette/src/frama-c/kernel/style.css +++ b/ivette/src/frama-c/kernel/style.css @@ -202,6 +202,19 @@ background-color: var(--background-report); } +/* -------------------------------------------------------------------------- */ +/* --- Server Logs --- */ +/* -------------------------------------------------------------------------- */ + +.server-logs-tab { + padding: 8px 12px; + border-right: 1px solid var(--splitter); +} + +.server-logs-tab-active { + background-color: var(--background-report); +} + /* -------------------------------------------------------------------------- */ /* --- Locations --- */ /* -------------------------------------------------------------------------- */ diff --git a/ivette/src/frama-c/server.ts b/ivette/src/frama-c/server.ts index f82c6afa9de3c9fbd7c342abc2584a359c8579ad..d98b4554dfbc31d6e96f9bda875889ddf4271884 100644 --- a/ivette/src/frama-c/server.ts +++ b/ivette/src/frama-c/server.ts @@ -39,6 +39,7 @@ import * as Json from 'dome/data/json'; import { TextBuffer } from 'dome/text/richtext'; import { ChildProcess } from 'child_process'; import { client } from './client_socket'; +import { logModel, status as statutLog } from './kernel/ServerLogs'; // import { client } from './client_zmq'; // -------------------------------------------------------------------------- @@ -253,6 +254,8 @@ export async function start(): Promise<void> { case Status.OFF: case Status.FAILURE: case Status.RESTARTING: + logModel.clear(); + logModel.registerFeedback('Server starting...'); _status(Status.STARTING); try { await _launch(); @@ -262,6 +265,8 @@ export async function start(): Promise<void> { } return; case Status.HALTING: + logModel.clear(); + logModel.registerFeedback('Server restarting...'); _status(Status.RESTARTING); return; case Status.ON: @@ -330,6 +335,7 @@ export function kill(): void { case Status.HALTING: case Status.STARTING: case Status.RESTARTING: + logModel.registerFeedback('Killing server...'); _status(Status.HALTING); _kill(); return; @@ -363,10 +369,14 @@ export function restart(): void { return; case Status.ON: case Status.CMD: + logModel.clear(); + logModel.registerFeedback('Server restarting...'); _status(Status.RESTARTING); _shutdown(); return; case Status.HALTING: + logModel.clear(); + logModel.registerFeedback('Server restarting...'); _status(Status.RESTARTING); return; case Status.STARTING: @@ -527,6 +537,7 @@ async function _launch(): Promise<void> { _exit(false); return; }); + logModel.registerFeedback('Socket server is running'); // Connect to Server client.connect(sockaddr); } @@ -574,6 +585,7 @@ function _kill(): void { async function _shutdown(): Promise<void> { _clear(); client.shutdown(); + logModel.registerFeedback('Server shutdown'); const killingPromise = new Promise((resolve) => { if (!killingTimer) { if (process) { @@ -645,6 +657,7 @@ class SignalHandler { if (this.active && !this.listen) { this.listen = true; client.sigOn(this.id); + logModel.registerSignal(this.id, statutLog.SIGON); } } @@ -654,6 +667,7 @@ class SignalHandler { if (isRunning()) { this.listen = false; client.sigOff(this.id); + logModel.registerSignal(this.id, statutLog.SIGOFF); } } } @@ -787,6 +801,16 @@ export function send<In, Out>( if (!request.name) return Promise.reject('Undefined request'); const rid = `RQ.${rqCount}`; rqCount += 1; + const { kind, name } = request; + logModel.registerRequest( + { + rid, + kind, + name, + param: param as unknown as Json.json, + statut: statutLog.PENDING + } + ); const response: Response<Out> = new Promise<Out>((resolve, reject) => { const unwrap = (js: Json.json): void => { try { @@ -828,6 +852,7 @@ client.onConnect((err?: Error) => { _status(Status.FAILURE); _clear(); } else { + logModel.registerFeedback('Client connected'); _status(Status.CMD); _startPolling(); } @@ -838,6 +863,9 @@ client.onData((id: string, data: Json.json) => { if (p) { p.resolve(data); _resolved(id); + logModel.registerRequest( + { rid: id, param: data, statut: statutLog.RESOLVED } + ); } }); @@ -854,6 +882,9 @@ client.onRejected((id: string) => { if (p) { p.reject('rejected'); _resolved(id); + logModel.registerRequest( + { rid: id, statut: statutLog.REJECTED } + ); } }); @@ -862,6 +893,9 @@ client.onError((id: string, msg: string) => { if (p) { p.reject(`error (${msg})`); _resolved(id); + logModel.registerRequest( + { rid: id, param: msg, statut: statutLog.ERROR } + ); } });