|  | 
|  | 1 | +import { useRef } from "react"; | 
|  | 2 | +import * as signalR from "@microsoft/signalr"; | 
|  | 3 | +import { useImmer } from "use-immer"; | 
|  | 4 | +import { ConnectionStatus } from "../models"; | 
|  | 5 | +import { Tooltip, DataGridBody, DataGridRow, DataGrid, DataGridHeader, DataGridHeaderCell, DataGridCell, TableCellLayout, TableColumnDefinition, createTableColumn } from "@fluentui/react-components"; | 
|  | 6 | +import { StatusIndicator } from "./workflows/StatusIndicator"; | 
|  | 7 | + | 
|  | 8 | +function LiveTraceGrid(props: { headers: Record<string, string>; items: LogDataViewModel[] }) { | 
|  | 9 | +  // record key as columnId, value as column name | 
|  | 10 | +  function columns(items: Record<string, string>): TableColumnDefinition<LogDataViewModel>[] { | 
|  | 11 | +    return Object.entries(items).map(([key, value]) => | 
|  | 12 | +      createTableColumn<LogDataViewModel>({ | 
|  | 13 | +        columnId: key, | 
|  | 14 | +        compare: (a, b) => { | 
|  | 15 | +          return (a.columns[key] ?? "").localeCompare(b.columns[key] ?? ""); | 
|  | 16 | +        }, | 
|  | 17 | +        renderHeaderCell: () => { | 
|  | 18 | +          return value; | 
|  | 19 | +        }, | 
|  | 20 | +        renderCell: (item) => { | 
|  | 21 | +          const content = item.columns[key]; | 
|  | 22 | +          // showing tooltip if content is long | 
|  | 23 | +          return ( | 
|  | 24 | +            <TableCellLayout truncate> | 
|  | 25 | +              {content?.length > 18 ? ( | 
|  | 26 | +                <Tooltip positioning="above-start" content={content} relationship="description"> | 
|  | 27 | +                  <span>{content}</span> | 
|  | 28 | +                </Tooltip> | 
|  | 29 | +              ) : ( | 
|  | 30 | +                <>{content}</> | 
|  | 31 | +              )} | 
|  | 32 | +            </TableCellLayout> | 
|  | 33 | +          ); | 
|  | 34 | +        }, | 
|  | 35 | +      }), | 
|  | 36 | +    ); | 
|  | 37 | +  } | 
|  | 38 | +  return ( | 
|  | 39 | +    <DataGrid items={props.items} columns={columns(props.headers)} sortable resizableColumns focusMode="composite"> | 
|  | 40 | +      <DataGridHeader> | 
|  | 41 | +        <DataGridRow>{({ renderHeaderCell }) => <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>}</DataGridRow> | 
|  | 42 | +      </DataGridHeader> | 
|  | 43 | +      <DataGridBody<LogDataViewModel>> | 
|  | 44 | +        {({ item }) => <DataGridRow<LogDataViewModel> key={item.eventId.toString()}>{({ renderCell }) => <DataGridCell>{renderCell(item)}</DataGridCell>}</DataGridRow>} | 
|  | 45 | +      </DataGridBody> | 
|  | 46 | +    </DataGrid> | 
|  | 47 | +  ); | 
|  | 48 | +} | 
|  | 49 | + | 
|  | 50 | +enum LogLevel { | 
|  | 51 | +  Trace, | 
|  | 52 | +  Debug, | 
|  | 53 | +  Information, | 
|  | 54 | +  Warning, | 
|  | 55 | +  Error, | 
|  | 56 | +  Critical, | 
|  | 57 | +  None, | 
|  | 58 | +} | 
|  | 59 | + | 
|  | 60 | +interface LiveTraceLogProperty { | 
|  | 61 | +  eventId: number; | 
|  | 62 | +  eventName: string; | 
|  | 63 | +  template: string; | 
|  | 64 | +  logLevel: LogLevel; | 
|  | 65 | +} | 
|  | 66 | + | 
|  | 67 | +// time, eventId, eventName, logContext as columns, template + value, exceptionMessage | 
|  | 68 | +interface LiveTraceLogData { | 
|  | 69 | +  time: string; | 
|  | 70 | +  eventId: number; | 
|  | 71 | +  /** | 
|  | 72 | +   * This field provides the additional information useful for the log item. | 
|  | 73 | +   */ | 
|  | 74 | +  logContext: Record<string, string>; | 
|  | 75 | +  /** | 
|  | 76 | +   *  This field is used to fill the log message's template. | 
|  | 77 | +   */ | 
|  | 78 | +  values: string[]; | 
|  | 79 | +  /** | 
|  | 80 | +   * This field is the exception message that includes in the log item if any. | 
|  | 81 | +   */ | 
|  | 82 | +  exceptionMessage: string | undefined; | 
|  | 83 | +} | 
|  | 84 | + | 
|  | 85 | +interface LogDataViewModel { | 
|  | 86 | +  columns: Record<string, string>; | 
|  | 87 | +  data: LiveTraceLogData; | 
|  | 88 | +  eventId: number; | 
|  | 89 | +} | 
|  | 90 | + | 
|  | 91 | +interface StatusDetail { | 
|  | 92 | +  status: ConnectionStatus; | 
|  | 93 | +  message: string; | 
|  | 94 | +  level: LogLevel; | 
|  | 95 | +} | 
|  | 96 | + | 
|  | 97 | +export function LiveTraceSection({ url, tokenGenerator }: { url: string; tokenGenerator(): Promise<string> }) { | 
|  | 98 | +  const connectionRef = useRef<signalR.HubConnection | undefined>(undefined); | 
|  | 99 | +  const logProps: Record<number, LiveTraceLogProperty> = {}; | 
|  | 100 | + | 
|  | 101 | +  const [logItems, updateLogItems] = useImmer<LogDataViewModel[]>([]); | 
|  | 102 | +  const [headers, updateHeaders] = useImmer<Record<string, string>>({ | 
|  | 103 | +    time: "Time", | 
|  | 104 | +    eventId: "EventId", | 
|  | 105 | +    eventName: "EventName", | 
|  | 106 | +    message: "Event", // template + value | 
|  | 107 | +    // + dynamic logContext as columns | 
|  | 108 | +    // + exception as column if exceptionMessage is not empty | 
|  | 109 | +  }); | 
|  | 110 | + | 
|  | 111 | +  function getViewModel(data: LiveTraceLogData, template: LiveTraceLogProperty | undefined): LogDataViewModel { | 
|  | 112 | +    const model: LogDataViewModel = { | 
|  | 113 | +      data: data, | 
|  | 114 | +      columns: { | 
|  | 115 | +        ...data.logContext, | 
|  | 116 | +        time: data.time, | 
|  | 117 | +        eventId: data.eventId.toString(), | 
|  | 118 | +      }, | 
|  | 119 | +      eventId: data.eventId, | 
|  | 120 | +    }; | 
|  | 121 | +    if (data.exceptionMessage) { | 
|  | 122 | +      // only add exception column when needed | 
|  | 123 | +      model.columns["exception"] = data.exceptionMessage; | 
|  | 124 | +    } | 
|  | 125 | + | 
|  | 126 | +    // if template is yet undefined, placeholding the message | 
|  | 127 | +    if (template) { | 
|  | 128 | +      updateViewModel(model, template); | 
|  | 129 | +    } | 
|  | 130 | + | 
|  | 131 | +    return model; | 
|  | 132 | +  } | 
|  | 133 | + | 
|  | 134 | +  function updateViewModel(model: LogDataViewModel, template: LiveTraceLogProperty): void { | 
|  | 135 | +    model.columns.eventName = template.eventName; | 
|  | 136 | +    model.columns.message = template.template.replace(/{(\w+)}/g, (match) => { | 
|  | 137 | +      return model.data.values.shift() ?? match; | 
|  | 138 | +    }); | 
|  | 139 | +  } | 
|  | 140 | + | 
|  | 141 | +  function updateColumnHeaders(data: LiveTraceLogData) { | 
|  | 142 | +    // update header incase new column is added from logContext | 
|  | 143 | +    updateHeaders((h) => { | 
|  | 144 | +      Object.entries(data.logContext).forEach(([key, _]) => { | 
|  | 145 | +        if (!h[key]) { | 
|  | 146 | +          // display name is the key | 
|  | 147 | +          h[key] = key; | 
|  | 148 | +        } | 
|  | 149 | +      }); | 
|  | 150 | +    }); | 
|  | 151 | +  } | 
|  | 152 | + | 
|  | 153 | +  const [status, updateStatus] = useImmer<StatusDetail>({ status: ConnectionStatus.Disconnected, message: "", level: LogLevel.None }); | 
|  | 154 | + | 
|  | 155 | +  const connect = () => { | 
|  | 156 | +    if (connectionRef.current) { | 
|  | 157 | +      return connectionRef.current; | 
|  | 158 | +    } | 
|  | 159 | + | 
|  | 160 | +    const connection = new signalR.HubConnectionBuilder() | 
|  | 161 | +      .withUrl(url, { | 
|  | 162 | +        accessTokenFactory: tokenGenerator, | 
|  | 163 | +        skipNegotiation: true, | 
|  | 164 | +        transport: signalR.HttpTransportType.WebSockets, | 
|  | 165 | +      }) | 
|  | 166 | +      .withAutomaticReconnect({ | 
|  | 167 | +        nextRetryDelayInMilliseconds: () => 3000, | 
|  | 168 | +      }) | 
|  | 169 | +      .configureLogging(signalR.LogLevel.Information) | 
|  | 170 | +      .build(); | 
|  | 171 | + | 
|  | 172 | +    const startListeningToLogEvents = () => { | 
|  | 173 | +      connection.send("startListeningToLogEvents").catch((err) => { | 
|  | 174 | +        console.error(err); | 
|  | 175 | +      }); | 
|  | 176 | +    }; | 
|  | 177 | +    connectionRef.current = connection; | 
|  | 178 | + | 
|  | 179 | +    connection.on("logEvent", (logEvent: LiveTraceLogData) => { | 
|  | 180 | +      const template = logProps[logEvent.eventId]; | 
|  | 181 | +      if (!template) { | 
|  | 182 | +        // get messageTemplate from logProps and show the log item | 
|  | 183 | +        connection.send("LogProperty", logEvent.eventId); | 
|  | 184 | +      } | 
|  | 185 | +      // incase logcontext contains more columns | 
|  | 186 | +      updateColumnHeaders(logEvent); | 
|  | 187 | +      // request for messageTemplate and then render the log item | 
|  | 188 | +      updateLogItems((i) => { | 
|  | 189 | +        i.unshift(getViewModel(logEvent, template)); | 
|  | 190 | +      }); | 
|  | 191 | +    }); | 
|  | 192 | + | 
|  | 193 | +    connection.on("LogProperty", (props: LiveTraceLogProperty) => { | 
|  | 194 | +      // only set when the event template is not yet set | 
|  | 195 | +      if (!logProps[props.eventId]) { | 
|  | 196 | +        logProps[props.eventId] = props; | 
|  | 197 | +        updateLogItems((i) => { | 
|  | 198 | +          const item = i.find((a) => a.eventId === props.eventId); | 
|  | 199 | +          if (item) { | 
|  | 200 | +            updateViewModel(item, props); | 
|  | 201 | +          } | 
|  | 202 | +        }); | 
|  | 203 | +      } | 
|  | 204 | +    }); | 
|  | 205 | +    connection.onclose((err) => { | 
|  | 206 | +      console.error(err); | 
|  | 207 | +      updateStatus((s) => { | 
|  | 208 | +        s.status = ConnectionStatus.Disconnected; | 
|  | 209 | +        s.message = err ? err.message : "Connection closed"; | 
|  | 210 | +        s.level = LogLevel.Error; | 
|  | 211 | +      }); | 
|  | 212 | +    }); | 
|  | 213 | +    connection.onreconnected(() => { | 
|  | 214 | +      updateStatus((s) => { | 
|  | 215 | +        s.status = ConnectionStatus.Connected; | 
|  | 216 | +        s.message = "Connection reconnected"; | 
|  | 217 | +        s.level = LogLevel.Information; | 
|  | 218 | +      }); | 
|  | 219 | +    }); | 
|  | 220 | +    connection | 
|  | 221 | +      .start() | 
|  | 222 | +      .then(() => { | 
|  | 223 | +        console.log("Connected, start listening to live traces"); | 
|  | 224 | +        startListeningToLogEvents(); | 
|  | 225 | +        updateStatus((s) => { | 
|  | 226 | +          s.status = ConnectionStatus.Connected; | 
|  | 227 | +          s.message = "Connected, start listening to live traces"; | 
|  | 228 | +          s.level = LogLevel.Information; | 
|  | 229 | +        }); | 
|  | 230 | +      }) | 
|  | 231 | +      .catch((err) => { | 
|  | 232 | +        console.error(err); | 
|  | 233 | +        updateStatus((s) => { | 
|  | 234 | +          s.status = ConnectionStatus.Disconnected; | 
|  | 235 | +          s.message = err ? err.message : "Connection closed"; | 
|  | 236 | +          s.level = LogLevel.Error; | 
|  | 237 | +        }); | 
|  | 238 | +      }); | 
|  | 239 | +  }; | 
|  | 240 | + | 
|  | 241 | +  connect(); | 
|  | 242 | + | 
|  | 243 | +  return ( | 
|  | 244 | +    <> | 
|  | 245 | +      <div className="m-2"> | 
|  | 246 | +        Status: <StatusIndicator status={status.status}></StatusIndicator> {status.message} | 
|  | 247 | +      </div> | 
|  | 248 | +      <LiveTraceGrid headers={headers} items={logItems} /> | 
|  | 249 | +    </> | 
|  | 250 | +  ); | 
|  | 251 | +} | 
0 commit comments