Implement client-side log rendering, better filtering, and pagination to improve performance and enhance the user experience with using the log parser.
This commit is contained in:
parent
df955c0d3e
commit
0beff189d1
|
@ -20,6 +20,16 @@
|
|||
.Cast<LogLevel>()
|
||||
.ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace);
|
||||
|
||||
IDictionary<int, string> logLevels = Enum
|
||||
.GetValues(typeof(LogLevel))
|
||||
.Cast<LogLevel>()
|
||||
.ToDictionary(level => (int)level, level => level.ToString().ToLower());
|
||||
|
||||
IDictionary<int, string> logSections = Enum
|
||||
.GetValues(typeof(LogSection))
|
||||
.Cast<LogSection>()
|
||||
.ToDictionary(section => (int)section, section => section.ToString());
|
||||
|
||||
string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true);
|
||||
|
||||
ISet<int> screenIds = new HashSet<int>(log?.Messages?.Select(p => p.ScreenId) ?? Array.Empty<int>());
|
||||
|
@ -34,8 +44,15 @@
|
|||
<link rel="stylesheet" href="~/Content/css/log-parser.css" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3/dist/css/tabby-ui-vertical.min.css" />
|
||||
|
||||
@if (!Model.ShowRaw)
|
||||
{
|
||||
<script id="logSections" type="application/json">@this.ForJson(logSections)</script>
|
||||
<script id="logLevels" type="application/json">@this.ForJson(logLevels)</script>
|
||||
<script id="parsedMessages" type="application/json">@this.ForJson(log?.Messages)</script>
|
||||
<script id="modSlugs" type="application/json">@this.ForJson(log?.Mods?.DistinctBy(m => m.Name).ToDictionary(m => m.Name, m => Model.GetSlug(m.Name)))</script>
|
||||
}
|
||||
<script src="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.11" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script>
|
||||
<script src="~/Content/js/file-upload.js"></script>
|
||||
<script src="~/Content/js/log-parser.js"></script>
|
||||
|
@ -275,29 +292,35 @@ else if (log?.IsValid == true)
|
|||
<span class="notice txt"><i>click any mod to filter</i></span>
|
||||
<span class="notice btn txt" v-on:click="showAllMods" v-bind:class="{ invisible: !anyModsHidden }">show all</span>
|
||||
<span class="notice btn txt" v-on:click="hideAllMods" v-bind:class="{ invisible: !anyModsShown || !anyModsHidden }">hide all</span>
|
||||
<span class="notice btn txt" v-on:click="toggleContentPacks">toggle content packs</span>
|
||||
}
|
||||
</caption>
|
||||
@foreach (var mod in log.Mods.Where(p => p.Loaded && !p.IsContentPack))
|
||||
{
|
||||
LogModInfo[]? contentPackList;
|
||||
if (contentPacks == null || !contentPacks.TryGetValue(mod.Name, out contentPackList))
|
||||
contentPackList = null;
|
||||
|
||||
<tr v-on:click="toggleMod('@Model.GetSlug(mod.Name)')" class="mod-entry" v-bind:class="{ hidden: !showMods['@Model.GetSlug(mod.Name)'] }">
|
||||
<td><input type="checkbox" v-bind:checked="showMods['@Model.GetSlug(mod.Name)']" v-bind:class="{ invisible: !anyModsHidden }" /></td>
|
||||
<td v-pre>
|
||||
<strong>@mod.Name</strong> @mod.Version
|
||||
@if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList))
|
||||
<td>
|
||||
<strong v-pre>@mod.Name</strong> @mod.Version
|
||||
@if (contentPackList != null)
|
||||
{
|
||||
<div class="content-packs">
|
||||
<div v-if="!hideContentPacks" class="content-packs">
|
||||
@foreach (var contentPack in contentPackList)
|
||||
{
|
||||
<text>+ @contentPack.Name @contentPack.Version</text><br />
|
||||
}
|
||||
</div>
|
||||
<span v-else class="content-packs--short"> (+ @contentPackList.Length Content Packs)</span>
|
||||
}
|
||||
</td>
|
||||
<td v-pre>
|
||||
<td>
|
||||
@mod.Author
|
||||
@if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out contentPackList))
|
||||
@if (contentPackList != null)
|
||||
{
|
||||
<div class="content-packs">
|
||||
<div v-if="!hideContentPacks" class="content-packs">
|
||||
@foreach (var contentPack in contentPackList)
|
||||
{
|
||||
<text>+ @contentPack.Author</text><br />
|
||||
|
@ -323,57 +346,67 @@ else if (log?.IsValid == true)
|
|||
|
||||
@if (!Model.ShowRaw)
|
||||
{
|
||||
<div id="filterHolder"></div>
|
||||
<div id="filters">
|
||||
Filter messages:
|
||||
<span v-bind:class="{ active: showLevels['trace'] }" v-on:click="toggleLevel('trace')">TRACE</span> |
|
||||
<span v-bind:class="{ active: showLevels['debug'] }" v-on:click="toggleLevel('debug')">DEBUG</span> |
|
||||
<span v-bind:class="{ active: showLevels['info'] }" v-on:click="toggleLevel('info')">INFO</span> |
|
||||
<span v-bind:class="{ active: showLevels['alert'] }" v-on:click="toggleLevel('alert')">ALERT</span> |
|
||||
<span v-bind:class="{ active: showLevels['warn'] }" v-on:click="toggleLevel('warn')">WARN</span> |
|
||||
<span v-bind:class="{ active: showLevels['error'] }" v-on:click="toggleLevel('error')">ERROR</span>
|
||||
<div class="toggles">
|
||||
<div>
|
||||
Filter messages:
|
||||
</div>
|
||||
<div>
|
||||
<span role="button" v-bind:class="{ active: showLevels['trace'] }" v-on:click="toggleLevel('trace')">TRACE</span> |
|
||||
<span role="button" v-bind:class="{ active: showLevels['debug'] }" v-on:click="toggleLevel('debug')">DEBUG</span> |
|
||||
<span role="button" v-bind:class="{ active: showLevels['info'] }" v-on:click="toggleLevel('info')">INFO</span> |
|
||||
<span role="button" v-bind:class="{ active: showLevels['alert'] }" v-on:click="toggleLevel('alert')">ALERT</span> |
|
||||
<span role="button" v-bind:class="{ active: showLevels['warn'] }" v-on:click="toggleLevel('warn')">WARN</span> |
|
||||
<span role="button" v-bind:class="{ active: showLevels['error'] }" v-on:click="toggleLevel('error')">ERROR</span>
|
||||
<div class="filter-text">
|
||||
<input
|
||||
type="text"
|
||||
v-bind:class="{ active: filterText && filterText != '' }"
|
||||
v-on:input="updateFilterText"
|
||||
placeholder="filter..."
|
||||
/>
|
||||
<span role="button" v-bind:class="{ active: filterUseRegex }" v-on:click="toggleFilterUseRegex" title="Use Regular Expression">.*</span>
|
||||
<span role="button" v-bind:class="{ active: !filterInsensitive }" v-on:click="toggleFilterInsensitive" title="Match Case">aA</span>
|
||||
<span role="button" v-bind:class="{ active: filterUseWord, 'whole-word': true }" v-on:click="toggleFilterWord" title="Match Whole Word"><i>Ab</i></span>
|
||||
<span role="button" v-bind:class="{ active: shouldHighlight }" v-on:click="toggleHighlight" title="Highlight Matches">HL</span>
|
||||
</div>
|
||||
<filter-stats
|
||||
v-bind:start="start"
|
||||
v-bind:end="end"
|
||||
v-bind:pages="totalPages"
|
||||
v-bind:filtered="filteredMessages.length"
|
||||
v-bind:total="totalMessages"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<pager
|
||||
v-bind:page="page"
|
||||
v-bind:pages="totalPages"
|
||||
v-bind:prevPage="prevPage"
|
||||
v-bind:nextPage="nextPage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<table id="log">
|
||||
@foreach (var message in log.Messages)
|
||||
{
|
||||
string levelStr = message.Level.ToString().ToLower();
|
||||
string sectionStartClass = message.IsStartOfSection ? "section-start" : null;
|
||||
string sectionFilter = message.Section != null && !message.IsStartOfSection ? $"&& sectionsAllow('{message.Section}')" : null; // filter the message by section if applicable
|
||||
<noscript>
|
||||
<div>
|
||||
This website uses JavaScript to display a filterable table. To view this log, please either
|
||||
<a href="@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID, format = LogViewFormat.RawView })">view the raw log</a>
|
||||
or enable JavaScript.
|
||||
</div>
|
||||
<br/>
|
||||
</noscript>
|
||||
|
||||
<tr class="mod @levelStr @sectionStartClass"
|
||||
@if (message.IsStartOfSection) { <text> v-on:click="toggleSection('@message.Section')" </text> }
|
||||
v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter">
|
||||
<td v-pre>@message.Time</td>
|
||||
@if (screenIds.Count > 1)
|
||||
{
|
||||
<td v-pre>screen_@message.ScreenId</td>
|
||||
}
|
||||
<td v-pre>@message.Level.ToString().ToUpper()</td>
|
||||
<td v-pre data-title="@message.Mod">@message.Mod</td>
|
||||
<td>
|
||||
<span v-pre class="log-message-text">@message.Text</span>
|
||||
@if (message.IsStartOfSection)
|
||||
{
|
||||
<span class="section-toggle-message">
|
||||
<template v-if="sectionsAllow('@message.Section')">
|
||||
This section is shown. Click here to hide it.
|
||||
</template>
|
||||
<template v-else>
|
||||
This section is hidden. Click here to show it.
|
||||
</template>
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
if (message.Repeated > 0)
|
||||
{
|
||||
<tr class="@levelStr mod mod-repeat" v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter">
|
||||
<td colspan="4"></td>
|
||||
<td v-pre><i>repeats [@message.Repeated] times.</i></td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</table>
|
||||
<log-table>
|
||||
<log-line
|
||||
v-for="msg in visibleMessages"
|
||||
v-bind:key="msg.id"
|
||||
v-bind:showScreenId="showScreenId"
|
||||
v-bind:message="msg"
|
||||
v-bind:highlight="shouldHighlight"
|
||||
v-bind:sectionExpanded="msg.SectionName && visibleSections.includes(msg.SectionName)"
|
||||
/>
|
||||
</log-table>
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
|
@ -148,6 +148,10 @@ table caption {
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
.content-packs--short {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
#metadata td:first-child {
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
@ -158,8 +162,58 @@ table caption {
|
|||
|
||||
#filters {
|
||||
margin: 1em 0 0 0;
|
||||
padding: 0;
|
||||
padding: 0 0 0.5em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: calc(100vw - 16em);
|
||||
}
|
||||
|
||||
#filters > div {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
#filters .toggles {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#filters .toggles > div:first-child {
|
||||
font-weight: bold;
|
||||
padding: 0.2em 1em 0 0;
|
||||
}
|
||||
|
||||
#filters .filter-text {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
#filters .stats {
|
||||
margin-top: 0.5em;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
#filters.sticky {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0em;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
padding: 0.5em;
|
||||
width: calc(100% - 1em);
|
||||
}
|
||||
|
||||
@media (min-width: 1020px) and (max-width: 1199px) {
|
||||
#filters:not(.sticky) {
|
||||
width: calc(100vw - 13em);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1019px) {
|
||||
#filters:not(.sticky) {
|
||||
width: calc(100vw - 3em);
|
||||
}
|
||||
|
||||
#filters {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
#filters span {
|
||||
|
@ -173,6 +227,17 @@ table caption {
|
|||
color: #000;
|
||||
border-color: #880000;
|
||||
background-color: #fcc;
|
||||
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#filters .filter-text span {
|
||||
padding: 3px 0.5em;
|
||||
}
|
||||
|
||||
#filters .whole-word i {
|
||||
padding: 0 1px;
|
||||
border: 1px dashed;
|
||||
}
|
||||
|
||||
#filters span:hover {
|
||||
|
@ -188,11 +253,48 @@ table caption {
|
|||
background: #efe;
|
||||
}
|
||||
|
||||
#filters .pager {
|
||||
margin-top: 0.5em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#filters .pager div {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
#filters .pager div span {
|
||||
padding: 0 0.5em;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
#filters .pager span {
|
||||
background-color: #eee;
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
#filters .pager span.active {
|
||||
font-weight: bold;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
#filters .pager span.disabled {
|
||||
opacity: 0.3;
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
#filters .pager span:not(.disabled):hover {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Log
|
||||
*********/
|
||||
#log .mod-repeat {
|
||||
font-size: 0.85em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#log .trace {
|
||||
|
@ -237,6 +339,11 @@ table caption {
|
|||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#log .log-message-text strong {
|
||||
background-color: yellow;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
#log {
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
|
|
@ -2,12 +2,98 @@
|
|||
|
||||
var smapi = smapi || {};
|
||||
var app;
|
||||
var messages;
|
||||
|
||||
|
||||
// Necessary helper method for updating our text filter in a performant way.
|
||||
// Wouldn't want to update it for every individually typed character.
|
||||
function debounce(fn, delay) {
|
||||
var timeoutID = null
|
||||
return function () {
|
||||
clearTimeout(timeoutID)
|
||||
var args = arguments
|
||||
var that = this
|
||||
timeoutID = setTimeout(function () {
|
||||
fn.apply(that, args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Case insensitive text searching and match word searching is best done in
|
||||
// regex, so if the user isn't trying to use regex, escape their input.
|
||||
function escapeRegex(text) {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Use a scroll event to apply a sticky effect to the filters / pagination
|
||||
// bar. We can't just use "position: sticky" due to how the page is structured
|
||||
// but this works well enough.
|
||||
$(function () {
|
||||
let sticking = false;
|
||||
|
||||
document.addEventListener('scroll', function (event) {
|
||||
const filters = document.getElementById('filters');
|
||||
const holder = document.getElementById('filterHolder');
|
||||
if (!filters || !holder)
|
||||
return;
|
||||
|
||||
const offset = holder.offsetTop;
|
||||
const should_stick = window.pageYOffset > offset;
|
||||
if (should_stick === sticking)
|
||||
return;
|
||||
|
||||
sticking = should_stick;
|
||||
if (sticking) {
|
||||
holder.style.marginBottom = `calc(1em + ${filters.offsetHeight}px)`;
|
||||
filters.classList.add('sticky');
|
||||
} else {
|
||||
filters.classList.remove('sticky');
|
||||
holder.style.marginBottom = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// This method is called when we click a log line to toggle the visibility
|
||||
// of a section. Binding methods is problematic with functional components
|
||||
// so we just use the `data-section` parameter and our global reference
|
||||
// to the app.
|
||||
smapi.clickLogLine = function (event) {
|
||||
app.toggleSection(event.currentTarget.dataset.section);
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
|
||||
// And these methods are called when doing pagination. Just makes things
|
||||
// easier, so may as well use helpers.
|
||||
smapi.prevPage = function () {
|
||||
app.prevPage();
|
||||
}
|
||||
|
||||
smapi.nextPage = function () {
|
||||
app.nextPage();
|
||||
}
|
||||
|
||||
smapi.changePage = function (event) {
|
||||
if (typeof event === 'number')
|
||||
app.changePage(event);
|
||||
else if (event) {
|
||||
const page = parseInt(event.currentTarget.dataset.page);
|
||||
if (!isNaN(page) && isFinite(page))
|
||||
app.changePage(page);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
smapi.logParser = function (data, sectionUrl) {
|
||||
if (!data)
|
||||
data = {};
|
||||
|
||||
// internal filter counts
|
||||
var stats = data.stats = {
|
||||
modsShown: 0,
|
||||
modsHidden: 0
|
||||
};
|
||||
|
||||
function updateModFilters() {
|
||||
// counts
|
||||
stats.modsShown = 0;
|
||||
|
@ -22,10 +108,357 @@ smapi.logParser = function (data, sectionUrl) {
|
|||
}
|
||||
}
|
||||
|
||||
// load our data
|
||||
|
||||
// Rather than pre-rendering the list elements into the document, we read
|
||||
// a lot of JSON and use Vue to build the list. This is a lot more
|
||||
// performant and easier on memory.Our JSON is stored in special script
|
||||
// tags, that we later remove to let the browser clean up even more memory.
|
||||
let nodeParsedMessages = document.querySelector('script#parsedMessages');
|
||||
if (nodeParsedMessages) {
|
||||
messages = JSON.parse(nodeParsedMessages.textContent) || [];
|
||||
const logLevels = JSON.parse(document.querySelector('script#logLevels').textContent) || {};
|
||||
const logSections = JSON.parse(document.querySelector('script#logSections').textContent) || {};
|
||||
const modSlugs = JSON.parse(document.querySelector('script#modSlugs').textContent) || {};
|
||||
|
||||
// Remove all references to the script tags and remove them from the
|
||||
// DOM so that the browser can clean them up.
|
||||
nodeParsedMessages.remove();
|
||||
document.querySelector('script#logLevels').remove();
|
||||
document.querySelector('script#logSections').remove();
|
||||
document.querySelector('script#modSlugs').remove();
|
||||
nodeParsedMessages = null;
|
||||
|
||||
// Pre-process the messages since they aren't quite serialized in
|
||||
// the format we want. We also want to freeze every last message
|
||||
// so that Vue won't install its change listening behavior.
|
||||
for (let i = 0, length = messages.length; i < length; i++) {
|
||||
const msg = messages[i];
|
||||
msg.id = i;
|
||||
msg.LevelName = logLevels && logLevels[msg.Level];
|
||||
msg.SectionName = logSections && logSections[msg.Section];
|
||||
msg.ModSlug = modSlugs && modSlugs[msg.Mod] || msg.Mod;
|
||||
|
||||
// For repeated messages, since our <log-line /> component
|
||||
// can't return two rows, just insert a second message
|
||||
// which will display as the message repeated notice.
|
||||
if (msg.Repeated > 0 && ! msg.isRepeated) {
|
||||
const second = {
|
||||
id: i + 1,
|
||||
Level: msg.Level,
|
||||
Section: msg.Section,
|
||||
Mod: msg.Mod,
|
||||
Repeated: msg.Repeated,
|
||||
isRepeated: true,
|
||||
};
|
||||
|
||||
messages.splice(i + 1, 0, second);
|
||||
length++;
|
||||
}
|
||||
|
||||
Object.freeze(msg);
|
||||
}
|
||||
|
||||
Object.freeze(messages);
|
||||
|
||||
} else
|
||||
messages = [];
|
||||
|
||||
// set local time started
|
||||
if (data)
|
||||
if (data.logStarted)
|
||||
data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2);
|
||||
|
||||
// Add some properties to the data we're passing to Vue.
|
||||
data.totalMessages = messages.length;
|
||||
|
||||
data.filterText = '';
|
||||
data.filterRegex = '';
|
||||
|
||||
data.showContentPacks = true;
|
||||
data.useHighlight = true;
|
||||
data.useRegex = false;
|
||||
data.useInsensitive = true;
|
||||
data.useWord = false;
|
||||
|
||||
data.perPage = 1000;
|
||||
data.page = 1;
|
||||
|
||||
// Now load these values.
|
||||
if (localStorage.settings) {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.settings);
|
||||
if (saved.hasOwnProperty('showContentPacks'))
|
||||
data.showContentPacks = saved.showContentPacks;
|
||||
if (saved.hasOwnProperty('useHighlight'))
|
||||
dat.useHighlight = saved.useHighlight;
|
||||
if (saved.hasOwnProperty('useRegex'))
|
||||
data.useRegex = saved.useRegex;
|
||||
if (saved.hasOwnProperty('useInsensitive'))
|
||||
data.useInsensitive = saved.useInsensitive;
|
||||
if (saved.hasOwnProperty('useWord'))
|
||||
data.useWord = saved.useWord;
|
||||
} catch { /* ignore errors */ }
|
||||
}
|
||||
|
||||
// This would be easier if we could just use JSX but this project doesn't
|
||||
// have a proper JavaScript build environment and I really don't feel
|
||||
// like setting one up.
|
||||
|
||||
// Add a number formatter so that our numbers look nicer.
|
||||
const fmt = window.Intl && Intl.NumberFormat && new Intl.NumberFormat();
|
||||
function formatNumber(value) {
|
||||
if (!fmt || !fmt.format) return `${value}`;
|
||||
return fmt.format(value);
|
||||
}
|
||||
Vue.filter('number', formatNumber);
|
||||
|
||||
// Strictly speaking, we don't need this. However, due to the way our
|
||||
// Vue template is living in-page the browser is "helpful" and moves
|
||||
// our <log-line />s outside of a basic <table> since obviously they
|
||||
// aren't table rows and don't belong inside a table. By using another
|
||||
// Vue component, we avoid that.
|
||||
Vue.component('log-table', {
|
||||
functional: true,
|
||||
render: function (createElement, context) {
|
||||
return createElement('table', {
|
||||
attrs: {
|
||||
id: 'log'
|
||||
}
|
||||
}, context.children);
|
||||
}
|
||||
});
|
||||
|
||||
// The <filter-stats /> component draws a nice message under the filters
|
||||
// telling a user how many messages match their filters, and also expands
|
||||
// on how many of them they're seeing because of pagination.
|
||||
Vue.component('filter-stats', {
|
||||
functional: true,
|
||||
render: function (createElement, context) {
|
||||
const props = context.props;
|
||||
if (props.pages > 1)
|
||||
return createElement('div', {
|
||||
class: 'stats'
|
||||
}, [
|
||||
'showing ',
|
||||
createElement('strong', formatNumber(props.start + 1)),
|
||||
' to ',
|
||||
createElement('strong', formatNumber(props.end)),
|
||||
' of ',
|
||||
createElement('strong', formatNumber(props.filtered)),
|
||||
' (total: ',
|
||||
createElement('strong', formatNumber(props.total)),
|
||||
')'
|
||||
]);
|
||||
|
||||
return createElement('div', {
|
||||
class: 'stats'
|
||||
}, [
|
||||
'showing ',
|
||||
createElement('strong', formatNumber(props.filtered)),
|
||||
' out of ',
|
||||
createElement('strong', formatNumber(props.total))
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// Next up we have <pager /> which renders the pagination list. This has a
|
||||
// helper method to make building the list of links easier.
|
||||
function addPageLink(page, links, visited, createElement, currentPage) {
|
||||
if (visited.has(page))
|
||||
return;
|
||||
|
||||
if (page > 1 && !visited.has(page - 1))
|
||||
links.push(' … ');
|
||||
|
||||
visited.add(page);
|
||||
links.push(createElement('span', {
|
||||
class: page == currentPage ? 'active' : null,
|
||||
attrs: {
|
||||
'data-page': page
|
||||
},
|
||||
on: {
|
||||
click: smapi.changePage
|
||||
}
|
||||
}, formatNumber(page)));
|
||||
}
|
||||
|
||||
Vue.component('pager', {
|
||||
functional: true,
|
||||
render: function (createElement, context) {
|
||||
const props = context.props;
|
||||
if (props.pages <= 1)
|
||||
return null;
|
||||
|
||||
const visited = new Set;
|
||||
const pageLinks = [];
|
||||
|
||||
for (let i = 1; i <= 2; i++)
|
||||
addPageLink(i, pageLinks, visited, createElement, props.page);
|
||||
|
||||
for (let i = props.page - 2; i <= props.page + 2; i++) {
|
||||
if (i < 1 || i > props.pages)
|
||||
continue;
|
||||
|
||||
addPageLink(i, pageLinks, visited, createElement, props.page);
|
||||
}
|
||||
|
||||
for (let i = props.pages - 2; i <= props.pages; i++) {
|
||||
if (i < 1)
|
||||
continue;
|
||||
|
||||
addPageLink(i, pageLinks, visited, createElement, props.page);
|
||||
}
|
||||
|
||||
return createElement('div', {
|
||||
class: 'pager'
|
||||
}, [
|
||||
createElement('span', {
|
||||
class: props.page <= 1 ? 'disabled' : null,
|
||||
on: {
|
||||
click: smapi.prevPage
|
||||
}
|
||||
}, 'Prev'),
|
||||
' ',
|
||||
'Page ',
|
||||
formatNumber(props.page),
|
||||
' of ',
|
||||
formatNumber(props.pages),
|
||||
' ',
|
||||
createElement('span', {
|
||||
class: props.page >= props.pages ? 'disabled' : null,
|
||||
on: {
|
||||
click: smapi.nextPage
|
||||
}
|
||||
}, 'Next'),
|
||||
createElement('div', {}, pageLinks)
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// Our <log-line /> functional component draws each log line.
|
||||
Vue.component('log-line', {
|
||||
functional: true,
|
||||
props: {
|
||||
showScreenId: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
sectionExpanded: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
highlight: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
render: function (createElement, context) {
|
||||
const msg = context.props.message;
|
||||
const level = msg.LevelName;
|
||||
|
||||
if (msg.isRepeated)
|
||||
return createElement('tr', {
|
||||
class: [
|
||||
"mod",
|
||||
level,
|
||||
"mod-repeat"
|
||||
]
|
||||
}, [
|
||||
createElement('td', {
|
||||
attrs: {
|
||||
colspan: context.props.showScreenId ? 4 : 3
|
||||
}
|
||||
}, ''),
|
||||
createElement('td', `repeats ${msg.Repeated} times`)
|
||||
]);
|
||||
|
||||
const events = {};
|
||||
let toggleMessage;
|
||||
if (msg.IsStartOfSection) {
|
||||
const visible = context.props.sectionExpanded;
|
||||
events.click = smapi.clickLogLine;
|
||||
toggleMessage = visible ?
|
||||
'This section is shown. Click here to hide it.' :
|
||||
'This section is hidden. Click here to show it.';
|
||||
}
|
||||
|
||||
let text = msg.Text;
|
||||
const filter = window.app && app.filterRegex;
|
||||
if (text && filter && context.props.highlight) {
|
||||
text = [];
|
||||
let match, consumed = 0, idx = 0;
|
||||
filter.lastIndex = -1;
|
||||
|
||||
// Our logic to highlight the text is a bit funky because we
|
||||
// want to group consecutive matches to avoid a situation
|
||||
// where a ton of single characters are in their own elements
|
||||
// if the user gives us bad input.
|
||||
|
||||
while (match = filter.exec(msg.Text)) {
|
||||
// Do we have an area of non-matching text? This
|
||||
// happens if the new match's index is further
|
||||
// along than the last index.
|
||||
if (match.index > idx) {
|
||||
// Alright, do we have a previous match? If
|
||||
// we do, we need to consume some text.
|
||||
if (consumed < idx)
|
||||
text.push(createElement('strong', {}, msg.Text.slice(consumed, idx)));
|
||||
|
||||
text.push(msg.Text.slice(idx, match.index));
|
||||
consumed = match.index;
|
||||
}
|
||||
|
||||
idx = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Add any trailing text after the last match was found.
|
||||
if (consumed < msg.Text.length) {
|
||||
if (consumed < idx)
|
||||
text.push(createElement('strong', {}, msg.Text.slice(consumed, idx)));
|
||||
|
||||
if (idx < msg.Text.length)
|
||||
text.push(msg.Text.slice(idx));
|
||||
}
|
||||
}
|
||||
|
||||
return createElement('tr', {
|
||||
class: [
|
||||
"mod",
|
||||
level,
|
||||
msg.IsStartOfSection ? "section-start" : null
|
||||
],
|
||||
attrs: {
|
||||
'data-section': msg.SectionName
|
||||
},
|
||||
on: events
|
||||
}, [
|
||||
createElement('td', msg.Time),
|
||||
context.props.showScreenId ? createElement('td', msg.ScreenId) : null,
|
||||
createElement('td', level.toUpperCase()),
|
||||
createElement('td', {
|
||||
attrs: {
|
||||
'data-title': msg.Mod
|
||||
}
|
||||
}, msg.Mod),
|
||||
createElement('td', [
|
||||
createElement('span', {
|
||||
class: 'log-message-text'
|
||||
}, text),
|
||||
msg.IsStartOfSection ? createElement('span', {
|
||||
class: 'section-toggle-message'
|
||||
}, [
|
||||
' ',
|
||||
toggleMessage
|
||||
]) : null
|
||||
])
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// init app
|
||||
app = new Vue({
|
||||
el: '#output',
|
||||
|
@ -36,9 +469,114 @@ smapi.logParser = function (data, sectionUrl) {
|
|||
},
|
||||
anyModsShown: function () {
|
||||
return stats.modsShown > 0;
|
||||
},
|
||||
showScreenId: function () {
|
||||
return this.screenIds.length > 1;
|
||||
},
|
||||
|
||||
// Maybe not strictly necessary, but the Vue template is being
|
||||
// weird about accessing data entries on the app rather than
|
||||
// computed properties.
|
||||
visibleSections: function () {
|
||||
const ret = [];
|
||||
for (const [k, v] of Object.entries(this.showSections))
|
||||
if (v !== false)
|
||||
ret.push(k);
|
||||
return ret;
|
||||
},
|
||||
hideContentPacks: function () {
|
||||
return !data.showContentPacks;
|
||||
},
|
||||
|
||||
// Filter messages for visibility.
|
||||
filterUseRegex: function () { return data.useRegex; },
|
||||
filterInsensitive: function () { return data.useInsensitive; },
|
||||
filterUseWord: function () { return data.useWord; },
|
||||
shouldHighlight: function () { return data.useHighlight; },
|
||||
|
||||
filteredMessages: function () {
|
||||
if (!messages)
|
||||
return [];
|
||||
|
||||
const start = performance.now();
|
||||
const ret = [];
|
||||
|
||||
// This is slightly faster than messages.filter(), which is
|
||||
// important when working with absolutely huge logs.
|
||||
for (let i = 0, length = messages.length; i < length; i++) {
|
||||
const msg = messages[i];
|
||||
if (msg.SectionName && !msg.IsStartOfSection && !this.sectionsAllow(msg.SectionName))
|
||||
continue;
|
||||
|
||||
if (!this.filtersAllow(msg.ModSlug, msg.LevelName))
|
||||
continue;
|
||||
|
||||
let text = msg.Text || (i > 0 ? messages[i - 1].Text : null);
|
||||
|
||||
if (this.filterRegex) {
|
||||
this.filterRegex.lastIndex = -1;
|
||||
if (!text || !this.filterRegex.test(text))
|
||||
continue;
|
||||
} else if (this.filterText && (!text || text.indexOf(this.filterText) == -1))
|
||||
continue;
|
||||
|
||||
ret.push(msg);
|
||||
}
|
||||
|
||||
const end = performance.now();
|
||||
console.log(`filter took ${end - start}ms`);
|
||||
|
||||
return ret;
|
||||
},
|
||||
|
||||
// And the rest are about pagination.
|
||||
start: function () {
|
||||
return (this.page - 1) * data.perPage;
|
||||
},
|
||||
end: function () {
|
||||
return this.start + this.visibleMessages.length;
|
||||
},
|
||||
totalPages: function () {
|
||||
return Math.ceil(this.filteredMessages.length / data.perPage);
|
||||
},
|
||||
//
|
||||
visibleMessages: function () {
|
||||
if (this.totalPages <= 1)
|
||||
return this.filteredMessages;
|
||||
|
||||
const start = this.start;
|
||||
const end = start + data.perPage;
|
||||
|
||||
return this.filteredMessages.slice(start, end);
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
this.loadFromUrl = this.loadFromUrl.bind(this);
|
||||
window.addEventListener('popstate', this.loadFromUrl);
|
||||
this.loadFromUrl();
|
||||
},
|
||||
methods: {
|
||||
// Mostly I wanted people to know they can override the PerPage
|
||||
// message count with a URL parameter, but we can read Page too.
|
||||
// In the future maybe we should read *all* filter state so a
|
||||
// user can link to their exact page state for someone else?
|
||||
loadFromUrl: function () {
|
||||
const params = new URL(location).searchParams;
|
||||
if (params.has('PerPage'))
|
||||
try {
|
||||
const perPage = parseInt(params.get('PerPage'));
|
||||
if (!isNaN(perPage) && isFinite(perPage) && perPage > 0)
|
||||
data.perPage = perPage;
|
||||
} catch { /* ignore errors */ }
|
||||
|
||||
if (params.has('Page'))
|
||||
try {
|
||||
const page = parseInt(params.get('Page'));
|
||||
if (!isNaN(page) && isFinite(page) && page > 0)
|
||||
this.page = page;
|
||||
} catch { /* ignore errors */ }
|
||||
},
|
||||
|
||||
toggleLevel: function (id) {
|
||||
if (!data.enableFilters)
|
||||
return;
|
||||
|
@ -46,6 +584,98 @@ smapi.logParser = function (data, sectionUrl) {
|
|||
this.showLevels[id] = !this.showLevels[id];
|
||||
},
|
||||
|
||||
toggleContentPacks: function () {
|
||||
data.showContentPacks = !data.showContentPacks;
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
toggleFilterUseRegex: function () {
|
||||
data.useRegex = !data.useRegex;
|
||||
this.saveSettings();
|
||||
this.updateFilterText();
|
||||
},
|
||||
|
||||
toggleFilterInsensitive: function () {
|
||||
data.useInsensitive = !data.useInsensitive;
|
||||
this.saveSettings();
|
||||
this.updateFilterText();
|
||||
},
|
||||
|
||||
toggleFilterWord: function () {
|
||||
data.useWord = !data.useWord;
|
||||
this.saveSettings();
|
||||
this.updateFilterText();
|
||||
},
|
||||
|
||||
toggleHighlight: function () {
|
||||
data.useHighlight = !data.useHighlight;
|
||||
this.saveSettings();
|
||||
},
|
||||
|
||||
prevPage: function () {
|
||||
if (this.page <= 1)
|
||||
return;
|
||||
this.page--;
|
||||
this.updateUrl();
|
||||
},
|
||||
|
||||
nextPage: function () {
|
||||
if (this.page >= this.totalPages)
|
||||
return;
|
||||
this.page++;
|
||||
this.updateUrl();
|
||||
},
|
||||
|
||||
changePage: function (page) {
|
||||
if (page < 1 || page > this.totalPages)
|
||||
return;
|
||||
this.page = page;
|
||||
this.updateUrl();
|
||||
},
|
||||
|
||||
// Persist settings into localStorage for use the next time
|
||||
// the user opens a log.
|
||||
saveSettings: function () {
|
||||
localStorage.settings = JSON.stringify({
|
||||
showContentPacks: data.showContentPacks,
|
||||
useRegex: data.useRegex,
|
||||
useInsensitive: data.useInsensitive,
|
||||
useWord: data.useWord,
|
||||
useHighlight: data.useHighlight
|
||||
});
|
||||
},
|
||||
|
||||
// Whenever the page is changed, replace the current page URL. Using
|
||||
// replaceState rather than pushState to avoid filling the tab history
|
||||
// with tons of useless history steps the user probably doesn't
|
||||
// really care about.
|
||||
updateUrl: function () {
|
||||
const url = new URL(location);
|
||||
url.searchParams.set('Page', data.page);
|
||||
url.searchParams.set('PerPage', data.perPage);
|
||||
|
||||
window.history.replaceState(null, document.title, url.toString());
|
||||
},
|
||||
|
||||
// We don't want to update the filter text often, so use a debounce with
|
||||
// a quarter second delay. We basically always build a regular expression
|
||||
// since we use it for highlighting, and it also make case insensitivity
|
||||
// much easier.
|
||||
updateFilterText: debounce(function () {
|
||||
let text = this.filterText = document.querySelector('input[type=text]').value;
|
||||
if (!text || !text.length) {
|
||||
this.filterText = '';
|
||||
this.filterRegex = null;
|
||||
} else {
|
||||
if (!data.useRegex)
|
||||
text = escapeRegex(text);
|
||||
this.filterRegex = new RegExp(
|
||||
data.useWord ? `\\b${text}\\b` : text,
|
||||
data.useInsensitive ? 'ig' : 'g'
|
||||
);
|
||||
}
|
||||
}, 250),
|
||||
|
||||
toggleMod: function (id) {
|
||||
if (!data.enableFilters)
|
||||
return;
|
||||
|
|
Loading…
Reference in New Issue