Search, Dialogs & Deployment

We’ve setup our endpoints to interact with the error log database, and created a small application to view the errors. Now its time to deploy our application.

Search, Dialogs & Deployment Prompt: Microsoft Image Creator

Today I deployed the error log application and added a few more features to navigate to the next/previous error log in a dialog using buttons or arrow keys, search for errors, highlight search terms, and lookup errors by their id.

First, I need to create a sub-domain. Compared to other web hosts, Hostinger makes this process fairly easy. You just enter a sub-domain and provide the name of a folder you would like to store you files in. With other hosts, my experience has been that they create a random folder name. When you have many sub-domains, its difficult to discern which folder belongs to each one.

I built my angular application and deployed. I’m running into a bit of a problem. It compiled my environment.ts file instead of environement.production.ts. For added measure, I flagged the build script with ng build --configuration production, but it’s still running with the default environment.

I had thought Angular automatically looked for these files, as many examples across the internet use these file names. The fix was simple. I had to go into angular.json and tell it to replace references to one file name with the other in a production build.

"configurations": {
  "production": {
    "fileReplacements": [
      {
        "replace": "src/environments/environment.ts",
        "with": "src/environments/environment.production.ts"
      }
    ]
}

Now my application is broken – as it should be. What I had failed to do was move my API endpoints from the development api site over to the error site.

Now my grid is blank and the API endpoint is giving me an error.

This makes sense. My secrets manager setup an ERROR_DATABASE for the dev-api subdomain. Now I need to enter the same secret for the errors subdomain. How am I going to pull this off? The secret manager uses the current machine name. It looks like my secrets manager needs a hook.

I’ve got my manager setup to create secrets for other scopes. Eventually the secrets manager needs to be setup as its own app. In the meantime… it worked!

Now that our error log endpoints live on the errors subdomain, we can drop them from the dev-api sub-domain.

I also want to move the error log file viewer, and the transfer scripts over to the errors subdomain as well. In this scenario, I need to change things around to handle multiple log files for many scopes, rather than the current host. This will also let me move all of the subdomain log files into the database using one cron job.

Our transfer script relies on a few secrets. The ERROR_OFFSET and ERROR_LOG, so that it knows where it left off with the last request, and where the error log is located. We could add another secret for ERROR_DIR. I had to do a few things. To ensure the transfer script knows which scope the error log belongs to, I changed each log to store the fully qualified domain name (FQDN) so that I would have dev-api.periplux.io.log instead of just dev-api.log. This way I have the scope simply by dropping the .log extension.

Actually using the error log viewer is proving to reveal some interesting insights. When viewing an individual error, I’d like some arrows to navigate to the prior and next errors. Also, as I’m often editing files, the line number becomes inefficient as I don’t know what the file looked like at the time the error was originally reported. I’d like to read a few lines prior and after the line number where the error occurred and attach it to the error log, and highlight the line accordingly.

I also recall a dream about the dates associated with each error. I have a lot of white space since they don’t offer much information. I wanted to display them as tiles instead. I could give them different background colors according to the duration or count that they represent.

Apparently I’ve been deleting my own files. I was baffled why the Secrets.php file went missing. During cleanup, my transfer script will delete the file if its reached the end. This works out well, but for some reason it was deleting my php scripts instead of the error log. I traced the bug down to where I was parsing the path from the error log. I was using the same $path variable as the log file itself. This means any PHP file that had an error was susceptible to being deleted… major problem. Given that I wired up the error log and exception handlers in my PHP scripts, the error log wasn’t being populated, so this bug didn’t appear until further testing today. Wow…

Error logs are now transferring. Some logs don’t pass the regex to parse message, path, line number, date, and error type. I changed the database to convert null values to empty strings, and to save them in the lookup table. As a result, I don’t show the hash image for empty paths. The line number is saved as -1 in the database, but on the front-end UI, I hide it. I also changed the table so that the message takes up as much real-estate possible without being cut short. Rather than specifying the width of the message, I needed to setup the table layout as being fixed and specify the width for each column.

As far as moving back and forth between errors, I added some buttons to the dialog and modified the dialog result to indicate what the next action should be – close or load a new dialog.

With each dialog being different sizes, I clamped down on the min/max width/height to get some consistency so that the buttons didn’t move when each new result appeared. When viewing the first or last error in the current page of results, it just wraps around to the other side of the current results. I could make a request to load next/prior pages, but for now, this is good enough.

Using Dialog Result to detect how it was closed
// Log Dialog Result
export interface LogDialogResult {
  action: 'previous' | 'next' | 'close',
  id: number
}

// Log Component
export class LogComponent {
  item: LogData
  constructor(
    @Inject(MAT_DIALOG_DATA) public data: LogData,
    public dialog: MatDialog,
    private dialogRef: MatDialogRef<LogComponent, LogDialogResult>
  ) {
    this.item = data;
  }
  next() {
    this.dialogRef.close({ action: 'next', id: this.item.id });
  }
  prior() {
    this.dialogRef.close({ action: 'previous', id: this.item.id });
  }
  close() {
    this.dialogRef.close({ action: 'close', id: this.item.id });
  }
}

// Logs Component
dialogRef.afterClosed().subscribe(dialogResult => {
      this.isDialogOpen = false;
      if (dialogResult === undefined || dialogResult.action === 'close') return;
      let index = this.data.findIndex(item => item.id === dialogResult.id);
      switch (dialogResult.action) {
        case 'next':
          index++;
          if (index >= this.data.length) index = 0;
          this.onRowClick(this.data[index]);
          break;
        case 'previous':
          index--;
          if (index < 0) index = this.data.length - 1;
          this.onRowClick(this.data[index]);
          break;
        default:
          return;
      }
    });

I’m not a fan of the dialog opening and closing. I’m wondering if I can update the dialog without closing it. Yes. I created an event emitter that the parent component was able to subscribe to. To update the dialog with the new data, I passed a callback function. Unfortunately, the data wasn’t updating. The answer was to bind(this) to the callback.

Using event emitters to update dialogs
// LogComponent

export class LogComponent {
  item: LogData
  nextItemEvent = new EventEmitter<NextLogEvent>();
  priorItemEvent = new EventEmitter<NextLogEvent>();
  setData(data: LogData) {
    this.item = data;
  }
  next() {
    this.nextItemEvent.emit({ id: this.item.id, setLog: this.setData.bind(this) });
  }
  prior() {
    this.priorItemEvent.emit({ id: this.item.id, setLog: this.setData.bind(this) });
  }
}
interface NextLogEvent {
  id: number,
  setLog: (data: LogData) => void
};

// LogsComponent
    const dialogRef = this.dialog.open<LogComponent, LogData, LogDialogResult>(LogComponent, config);
    dialogRef.componentInstance.nextItemEvent.subscribe(event => {
      let index = this.data.findIndex(item => item.id === event.id);
      index = ++index % this.data.length;
      event.setLog(this.data[index]);
    });
    dialogRef.componentInstance.priorItemEvent.subscribe(event => {
      let index = this.data.findIndex(item => item.id === event.id);
      index--;
      if (index < 0) index = this.data.length - 1;
      event.setLog(this.data[index]);
    });

Rather than getting lost as to which item on the grid is being displayed in the dialog, I decided to apply the hover effect to the currently selected row. I can now navigate previous/next errors and watch the selected row change in the background.

Well, since I’m at it selecting rows, why not add it as a query parameter? This way as I update the dialog, the page refreshes and opens the dialog that I was working on. If I ever need to send someone to look at a specific error, I can give them the link.

Of course, it wont work if the page data had changed over time. If the error changes to be on page 3, but the link still goes to page 2, the viewer wont see the dialog pop up until they navigate to page 3. Perhaps I need another endpoint to request specific errors.

Load selected item if not in list
loadData(pageIndex: number, pageSize: number) {
    if (pageIndex === this.pageIndex && pageSize === this.pageSize) {
      return;
    }
    this.logsService.getPage(pageIndex + 1, pageSize)
      .subscribe(response => {
        this.pageSize = pageSize;
        this.pageIndex = pageIndex;
        this.data = response.data;
        this.totalItems = response.total;
        this.updateQueryPrams();
        this.loadSelected();
      });
  }
  loadSelected() {
    if (this.selectedId === -1) return;
    const selected = this.data.find(({ id }) => id === this.selectedId);
    if (selected) {
      this.onRowClick(selected);
      return;
    }
    this.logsService.getItem(this.selectedId)
      .subscribe(item => {
        this.onRowClick(item);
      })
  }

Well, since we can now load up an individual log, we may as well add the capability to manually enter the id to lookup. No need to pass a link to someone. Just give them a number.

It looks a bit wonkey, but it does the job.

I started wiring up keyboard events with the HostListener directive so that the arrow keys will move to the prior/next errors.

setLog(data: LogData) {
  this.item = data;
}

@HostListener('keydown.arrowDown', ['$event'])
@HostListener('keydown.arrowRight', ['$event'])
next() {
  this.nextItemEvent.emit({ 
    id: this.item.id, 
    setLog: this.setLog.bind(this) 
  });
}

@HostListener('keydown.arrowUp', ['$event'])
@HostListener('keydown.arrowLeft', ['$event'])
prior() {
  this.priorItemEvent.emit({ 
    id: this.item.id, 
    setLog: this.setLog.bind(this) 
  });
}
close() {
  this.dialogRef.close();
}

This works well, except when the dialog doesn’t have focus. Which is often the case when I tab between windows. Somehow the keyboard events don’t work until I click part of the dialog. Can I do something about that?

I started looking at how to detect when I leave the browser and come back by hooking into the visibility state. In order to do that, I needed to inject the document in order to wire up the events, and then cache a bound handler to add/remove when the component initialized and was destroyed. As I was doing my testing, I noticed that I could just click outside of the dialog and lose my keyboard events. Clicking anywhere in the dialog didn’t appear to restore them until I clicked one of the prior/next buttons.

Wiring up the visibility state seemed to pay off as it lead me in the direction that I needed to go in. Rather than wiring up keyboard events to the component, I wanted to wire them up to the document directly so that they would work regardless of what had state.

Wire up document events in a component
boundKeydown: (event: KeyboardEvent) => void;

constructor(
  @Inject(MAT_DIALOG_DATA) public data: LogData,
  @Inject(DOCUMENT) private document: Document,
  public dialog: MatDialog,
  private dialogRef: MatDialogRef<LogComponent>
) {
  this.item = data;
  this.boundKeydown = this.handleKeydown.bind(this);
}
handleKeydown({ key, code }: KeyboardEvent) {
  const name = key ?? code;
  switch (name) {
    case 'ArrowLeft':
    case 'ArrowUp':
      this.prior();
      return;
    case 'ArrowRight':
    case 'ArrowDown':
      this.next();
      return;
    default: break;
  }
}
ngOnInit(): void {
  this.boundKeydown = this.handleKeydown.bind(this);
  this.document.addEventListener('keydown', this.boundKeydown);
}
ngOnDestroy(): void {
  this.document.removeEventListener('keydown', this.boundKeydown);
}

Can we search text for an error? It seems like that should be a given. We’ve got four text fields on the grid itself. Let’s start with that.

Search multiple fields
SELECT SQL_CALC_FOUND_ROWS
  l.`id`,
  s.`scope`,
  MIN(ld.`first_at`) AS `first_at`,
  MAX(ld.`last_at`) AS `last_at`,
  t.`type`,
  m.`message`,
  p.`path`,
  l.`line`,
  SUM(ld.`count`) AS `count`
FROM
  `logs` AS l
  INNER JOIN `scopes` AS s ON l.`scope_id` = s.`id`
  INNER JOIN `types` AS t ON l.`type_id` = t.`id`
  INNER JOIN `messages` AS m ON l.`message_id` = m.`id`
  INNER JOIN `paths` AS p ON l.`path_id` = p.`id`
  INNER JOIN `log_dates` AS ld ON ld.`log_id` = l.`id`
WHERE
  s.`scope` LIKE CONCAT('%', p_search, '%')
  OR t.`type` LIKE CONCAT('%', p_search, '%')
  OR m.`message` LIKE CONCAT('%', p_search, '%')
  OR p.`path` LIKE CONCAT('%', p_search, '%')
GROUP BY
  l.`id`,
  s.`scope`,
  t.`type`,
  m.`message`,
  p.`path`,
  l.`line`
ORDER BY
  MAX(ld.`last_at`) DESC,
  MIN(ld.`first_at`) ASC
LIMIT p_page_size OFFSET v_page_offset;

Well… it works. However, I’m noticing a few things looking back at my code. First – I’m duplicating the concatenation four times. I could just store that as a variable. However, that’s not what concerns me. Most of the time, I imagine that the search is empty. In this scenario, we are searching the text fields for %% when they do not need to be searched.

DECLARE v_pattern VARCHAR(1026);
IF p_search IS NULL THEN
  SET p_search = '';
END IF;
SET p_search = TRIM(p_search);
SET v_pattern = CONCAT('%', p_search, '%');

-- select fields from tables ...
WHERE
  p_search = ''
  OR (
    s.`scope` LIKE v_pattern
    OR t.`type` LIKE v_pattern
    OR m.`message` LIKE v_pattern
    OR p.`path` LIKE v_pattern
  )

That looks a bit better. Now… can we search the details table containing stack traces? Yes – as well as the detail types so that I can simply search for “Stack Trace” and find every log that has a stack trace associated with it. Although it’s not a full text search with relevance, this simple query gets the job done.

Can we highlight the matching search expression in the grid? To highlight we HTML tags, we need to set our content to innerHTML attributes rather than display as the text. I’ve created a little pipe called highlight that wraps my search terms with span tags. The text shows up, but the span tags don’t appear to be capturing any of the styled classes.

<div
  class="message"
  *ngIf="searchText"
  innerHTML="{{item.message | highlight: searchText}}">
</div>

Are the tags actually applied? Yes – they show up when I inspect the page with highlight classes.

Rather than piping, I decided to just build up an array of parts and loop through them, alternating the class names of highlighting or no highlighting. Again, I used the little trick of having semi-transparent backgrounds to allow highlighting and alternating row colors shine through. I dimmed the surrounding text a little, only if the row was found to have a match.

Now… SQL allows me to use wildcards such as a single character using underscore _ and any number of characters using percent %. If I wanted to find the word Failed and Open, I can search for Failed%Open. Searching for Too and Two can be done with T_o. Currently, my little highlighter isn’t equipped to handle that. Let’s see if we can address it. Single characters are easy. We just replace the underscore in our regex with a dot. The percent sign is difficult because we don’t want to highlight anything that the percent sign matches.

We got something working. However, its not intuitive to type a percent sign. Most people don’t think in SQL. They just type spaces. Let’s convert all of our spaces to wildcards.

What if we want entire phrases? People usually put quotes around them. Can we address that? If I currently try to use quotes, nothing will match.

Searching without quoted phrases
Searching with quoted phrases
Convert search terms into SQL Like
sqlLike(search: string) {
  if (search.trim() === '') return '';
  if (!search.includes('"')) return search.trim().replaceAll(' ', '%');
  // every space outside of quotes changes to percent 
  // remove quotes
  let like = '';
  const pattern = /([^"]*)"([^"]*)"([^"]*)/g;
  const matches = search.matchAll(pattern);

  for (const match of matches) {
    // unquoted prefix
    if (match[1].trim() !== '') {
      if (like.length !== 0) like += '%';
      like += match[1].trim().replaceAll(' ', '%');
    }
    // quoted
    if (match[2].trim() !== '') {
      if (like.length !== 0) like += '%';
      like += match[2].trim();
    }
    // unquoted suffix
    if (match[3].trim() !== '') {
      if (like.length !== 0) like += '%';
      like += match[3].trim().replaceAll(' ', '%');
    }
  }
  return like;
}
Highlight Search Terms
const highlightedClass = 'highlight';
const normalClass = 'no-light';

export const highlightSearchTerms = (text: string, searchText: string) => {
  if (searchText.length === 0) return;

  let i = 0;
  const parts = [];
  const pattern = buildPattern(searchText);
  if (!pattern.test(text)) {
    return [{ text, className: '' }];
  }
  pattern.lastIndex = 0;
  let matches: RegExpExecArray | null = null;
  while ((matches = pattern.exec(text)) !== null) {
    if (matches === null) break;
    if (i++ > 5) break;
    const matchIndex = matches.index;
    const endIndex = matchIndex + matches[0].length
    if (matchIndex !== 0) {
      parts.push({
        text: text.substring(0, matchIndex),
        className: normalClass
      });
    }

    if (matches.length === 1) {
      parts.push({
        text: matches[0],
        className: highlightedClass
      });
    } else {
      for (let n = 1; n < matches.length; n++) {
        parts.push({
          text: matches[n],
          className: n % 2 === 0 ? normalClass : highlightedClass
        });
      }
    }

    if (endIndex >= text.length - 1) {
      text = '';
    } else {
      text = text.substring(endIndex);
    }
    pattern.lastIndex = 0;
  }
  if (text.length !== 0) {
    parts.push({
      text,
      className: normalClass
    });
  }
  return parts;
}

const buildPattern = (searchText: string) => {
  let encoded = searchText
    // regex encoding
    .replaceAll(/([[.*+?^$`()\\\]])/g, '\\$1')
    // single character
    .replaceAll('_', '.')
    // quotes
    .replaceAll(/"([^"]*?)"/g, (substring: string, ...args: any) => {
      // remove quotes and
      // prevent space from being 
      // turned into wildcard later
      return args[0].replaceAll(' ', '\\s');
    })
    // Trim wildcards
    .replace(/^%*(.*?)%*$/, '$1')
    ;
  if (encoded.includes('%') || encoded.includes(' ')) {
    // group wildcards % and spaces
    encoded = '(' + encoded.replaceAll(/[% ]/g, ')(.*)(') + ')';
  }
  return new RegExp(encoded, 'gi');
}
Display highlighted terms in template
<div class="message" *ngIf="searchText">
  <span *ngFor="let part of searchParts(item.message)">
    <span class="{{part.className}}">{{part.text}}</span>
  </span>
</div>
<div class="message" *ngIf="!searchText">{{item.message}}</div>
Sassy CSS highlighted terms
.highlight {
  background-color: rgba(255, 255, 0, 0.33);
  font-weight: bolder;
  color: black;
}
.no-light {
  color: rgb(100, 100, 100);
}

I went ahead and highlighted the path in the log table, and then proceeded to highlight the fields when viewing an individual error including the stack trace.

By the looks of it, I broke the overflow for some of the long text that can’t wrap. That’s a simple fix for setting overflow to auto.

As a final measure, lets wire up the search box so that if it is just a number, then we try to load the corresponding error.

I think we are pretty good to go. Let’s deploy the latest changes to production. Everything is live.

I changed the text hash icons down to a 2×2 cell. As I was using the log a bit more, the cells seem a bit busy and distracting. Having just four colors seemed to keep them a bit simpler without calling too much attention to themselves when they aren’t needed.

Wrap Up

  • Crated a new subdomain for error logs
  • Deployed Error Logs application
  • Optimized layout of column widths so message can take up the most space
  • Reconfigured secrets manager to edit scopes for other hosts
  • error parser/transfer script now works with multiple error logs
  • For invalid formatted logs, error parser now assigns current timestamp, -1 as line number, and no path
  • Database now allows for empty/null path, message, scope, and details (stack trace)
  • Hash icons are not displayed for empty values
  • Negative line numbers are hidden
  • Customized environment files allow production to have different settings.
  • Duplicate errors are now consolidated within 60 second window
  • Use dialog result to communicate users intentions once the dialog is closed
  • Used event emitters to communicate with parent component
  • Focused “next” in the dialog so that default behavior for [Enter] key moves to the next error.
  • Update dialog data from parent component via event callback
  • Navigate errors with keyboard events using Hostbinding and document listener
  • Experimented with visibility state to detect when the user returns to the browser from another application/tab
  • Added feature to link to an error
  • Created procedure and endpoint to load an individual error
  • Added feature to lookup error by id
  • Added feature to search for errors
  • Added feature to search for quoted phrases
  • Changed hash images
  • Highlight search terms and phrases in search results and error dialog
  • Added feature to link to search results

What’s next

I have a few things I could do. Authentication is coming up pretty soon for the application as a whole. In the meantime, I need to start touching some of the basics. Here are a few things on the backlog.

  • Error handling with API calls – specifically if a log wasn’t found, present the end-user with a message.
  • Button to manually initiate error log transfer from file system to database
  • Secrets Manager UI
  • Authentication for secrets manager and error log
  • 2FA for error log
  • Automated deployment for errors
  • Rate limiting for 2FA and Authentication
  • Better dropbox integration where the token to backup encryption keys doesn’t expire
  • Setup a cron job – transfer error logs

One response to “Search, Dialogs & Deployment”

Discover more from Lewis Moten

Subscribe now to keep reading and get access to the full archive.

Continue reading