move logviewer to own package

This commit is contained in:
Bruno Windels 2022-05-10 18:03:15 +02:00
parent 1d900b5184
commit f6ea7803f2
7 changed files with 7 additions and 829 deletions

View file

@ -26,6 +26,7 @@
}, },
"homepage": "https://github.com/vector-im/hydrogen-web/#readme", "homepage": "https://github.com/vector-im/hydrogen-web/#readme",
"devDependencies": { "devDependencies": {
"@matrixdotorg/structured-logviewer": "^0.0.1",
"@typescript-eslint/eslint-plugin": "^4.29.2", "@typescript-eslint/eslint-plugin": "^4.29.2",
"@typescript-eslint/parser": "^4.29.2", "@typescript-eslint/parser": "^4.29.2",
"acorn": "^8.6.0", "acorn": "^8.6.0",

View file

@ -1,51 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function openFile(mimeType = null) {
const input = document.createElement("input");
input.setAttribute("type", "file");
input.className = "hidden";
if (mimeType) {
input.setAttribute("accept", mimeType);
}
const promise = new Promise((resolve, reject) => {
const checkFile = () => {
input.removeEventListener("change", checkFile, true);
const file = input.files[0];
document.body.removeChild(input);
if (file) {
resolve(file);
} else {
reject(new Error("no file picked"));
}
}
input.addEventListener("change", checkFile, true);
});
// IE11 needs the input to be attached to the document
document.body.appendChild(input);
input.click();
return promise;
}
export function readFileAsText(file) {
const reader = new FileReader();
const promise = new Promise((resolve, reject) => {
reader.addEventListener("load", evt => resolve(evt.target.result));
reader.addEventListener("error", evt => reject(evt.target.error));
});
reader.readAsText(file);
return promise;
}

View file

@ -1,110 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// DOM helper functions
export function isChildren(children) {
// children should be an not-object (that's the attributes), or a domnode, or an array
return typeof children !== "object" || !!children.nodeType || Array.isArray(children);
}
export function classNames(obj, value) {
return Object.entries(obj).reduce((cn, [name, enabled]) => {
if (typeof enabled === "function") {
enabled = enabled(value);
}
if (enabled) {
return cn + (cn.length ? " " : "") + name;
} else {
return cn;
}
}, "");
}
export function setAttribute(el, name, value) {
if (name === "className") {
name = "class";
}
if (value === false) {
el.removeAttribute(name);
} else {
if (value === true) {
value = name;
}
el.setAttribute(name, value);
}
}
export function el(elementName, attributes, children) {
return elNS(HTML_NS, elementName, attributes, children);
}
export function elNS(ns, elementName, attributes, children) {
if (attributes && isChildren(attributes)) {
children = attributes;
attributes = null;
}
const e = document.createElementNS(ns, elementName);
if (attributes) {
for (let [name, value] of Object.entries(attributes)) {
if (name === "className" && typeof value === "object" && value !== null) {
value = classNames(value);
}
setAttribute(e, name, value);
}
}
if (children) {
if (!Array.isArray(children)) {
children = [children];
}
for (let c of children) {
if (!c.nodeType) {
c = text(c);
}
e.appendChild(c);
}
}
return e;
}
export function text(str) {
return document.createTextNode(str);
}
export const HTML_NS = "http://www.w3.org/1999/xhtml";
export const SVG_NS = "http://www.w3.org/2000/svg";
export const TAG_NAMES = {
[HTML_NS]: [
"br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
"p", "strong", "em", "span", "img", "section", "main", "article", "aside",
"pre", "button", "time", "input", "textarea", "label", "form", "progress", "output"],
[SVG_NS]: ["svg", "circle"]
};
export const tag = {};
for (const [ns, tags] of Object.entries(TAG_NAMES)) {
for (const tagName of tags) {
tag[tagName] = function(attributes, children) {
return elNS(ns, tagName, attributes, children);
}
}
}

View file

@ -1,237 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style type="text/css">
html, body {
height: 100%;
}
body {
font-family: sans-serif;
font-size: 1rem;
margin: 0;
display: grid;
grid-template-areas: "nav nav" "items details";
grid-template-columns: 1fr 400px;
grid-template-rows: auto 1fr;
min-height: 0;
}
main {
grid-area: items;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: 8px;
}
main section h2 {
margin: 2px 14px;
font-size: 1rem;
}
aside {
grid-area: details;
padding: 8px;
}
aside h3 {
word-wrap: anywhere;
}
aside p {
margin: 2px 0;
}
aside .values li span {
word-wrap: ;
word-wrap: anywhere;
padding: 4px;
}
aside .values {
list-style: none;
padding: 0;
border: 1px solid lightgray;
}
aside .values span.key {
width: 30%;
display: block;
}
aside .values span.value {
width: 70%;
display: block;
white-space: pre-wrap;
}
aside .values li {
display: flex;
}
aside .values li:not(:first-child) {
border-top: 1px solid lightgray;
}
nav {
grid-area: nav;
}
.timeline li:not(.expanded) > ol {
display: none;
}
.timeline li > div {
display: flex;
}
.timeline .toggleExpanded {
border: none;
background: none;
width: 24px;
height: 24px;
margin-right: 4px;
cursor: pointer;
}
.timeline .toggleExpanded:before {
content: "▶";
}
.timeline li.expanded > div > .toggleExpanded:before {
content: "▼";
}
.timeline ol {
list-style: none;
padding: 0 0 0 20px;
margin: 0;
}
.timeline .item {
--hue: 100deg;
--brightness: 80%;
background-color: hsl(var(--hue), 60%, var(--brightness));
border: 1px solid hsl(var(--hue), 60%, calc(var(--brightness) - 40%));
border-radius: 4px;
padding: 2px;
display: flex;
margin: 1px;
flex: 1;
min-width: 0;
color: inherit;
text-decoration: none;
}
.timeline .item:not(.has-children) {
margin-left: calc(24px + 4px + 1px);
}
.timeline .item .caption {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
}
.timeline .item.level-3 {
--brightness: 90%;
}
.timeline .item.level-2 {
--brightness: 95%;
}
.timeline .item.level-5 {
--brightness: 80%;
}
.timeline .item.level-6, .timeline .item.level-7 {
--hue: 0deg !important;
}
.timeline .item.level-7 {
--brightness: 50%;
color: white;
}
.timeline .item.type-network {
--hue: 30deg;
}
.timeline .item.type-call {
--hue: 50deg;
}
.timeline .item.type-navigation {
--hue: 200deg;
}
.timeline .item.selected {
background-color: Highlight;
border-color: Highlight;
color: HighlightText;
}
.timeline .item.highlighted {
background-color: fuchsia;
color: white;
}
.hidden {
display: none;
}
#highlight {
width: 300px;
}
nav form {
display: inline;
}
#filename {
margin: 0;
font-size: 1rem;
padding: 4px 0;
}
</style>
</head>
<body>
<nav>
<div>
<button id="openFile">Open log file</button>
<button id="collapseAll">Collapse all</button>
<button id="hideCollapsed">Hide collapsed root items</button>
<button id="hideHighlightedSiblings" title="Hide collapsed siblings of highlighted">Hide non-highlighted</button>
<button id="showAll">Show all</button>
<form id="highlightForm">
<input type="text" id="highlight" name="highlight" placeholder="Highlight a search term" autocomplete="on">
<output id="highlightMatches"></output>
</form>
</div>
<h2 id="filename"></h2>
</nav>
<main></main>
<aside></aside>
<script type="module" src="main.js"></script>
<script type="module">
import {loadBlob} from "./main.js";
window.addEventListener("message", event => {
console.log("message", event);
if (event.data.type === "open") {
document.getElementById("filename").innerText = "Loading logs from other tab...";
loadBlob(event.data.logs).then(() => {
document.getElementById("filename").innerText = "Logs from other tab at " + new Date();
});
} else if (event.data.type === "ping") {
document.getElementById("filename").innerText = "Waiting for logs from other tab...";
event.source.postMessage({type: "pong"});
}
});
</script>
</body>
</html>

View file

@ -1,430 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {tag as t} from "./html.js";
import {openFile, readFileAsText} from "./file.js";
const main = document.querySelector("main");
let selectedItemNode;
let rootItem;
let itemByRef;
let itemsRefFrom;
const logLevels = [undefined, "All", "Debug", "Detail", "Info", "Warn", "Error", "Fatal", "Off"];
main.addEventListener("click", event => {
if (event.target.classList.contains("toggleExpanded")) {
const li = event.target.parentElement.parentElement;
li.classList.toggle("expanded");
} else {
// allow clicking any links other than .item in the timeline, like refs
if (event.target.tagName === "A" && !event.target.classList.contains("item")) {
return;
}
const itemNode = event.target.closest(".item");
if (itemNode) {
// we don't want scroll to jump when clicking
// so prevent default behaviour, and select and push to history manually
event.preventDefault();
selectNode(itemNode);
history.pushState(null, null, `#${itemNode.id}`);
}
}
});
window.addEventListener("hashchange", () => {
const id = window.location.hash.substr(1);
const itemNode = document.getElementById(id);
if (itemNode && itemNode.closest("main")) {
ensureParentsExpanded(itemNode);
selectNode(itemNode);
itemNode.scrollIntoView({behavior: "smooth", block: "nearest"});
}
});
function selectNode(itemNode) {
if (selectedItemNode) {
selectedItemNode.classList.remove("selected");
}
selectedItemNode = itemNode;
selectedItemNode.classList.add("selected");
let item = rootItem;
let parent;
const indices = selectedItemNode.id.split("/").map(i => parseInt(i, 10));
for(const i of indices) {
parent = item;
item = itemChildren(item)[i];
}
showItemDetails(item, parent, selectedItemNode);
}
function ensureParentsExpanded(itemNode) {
let li = itemNode.parentElement.parentElement;
while (li.tagName === "LI") {
li.classList.add("expanded");
li = li.parentElement.parentElement;
}
}
function stringifyItemValue(value) {
if (typeof value === "object" && value !== null) {
return JSON.stringify(value, undefined, 2);
} else {
return value + "";
}
}
function showItemDetails(item, parent, itemNode) {
const parentOffset = itemStart(parent) ? `${itemStart(item) - itemStart(parent)}ms` : "none";
const expandButton = t.button("Expand recursively");
expandButton.addEventListener("click", () => expandResursively(itemNode.parentElement.parentElement));
const start = itemStart(item);
const aside = t.aside([
t.h3(itemCaption(item)),
t.p([t.strong("Log level: "), logLevels[itemLevel(item)]]),
t.p([t.strong("Error: "), itemError(item) ? `${itemError(item).name} ${itemError(item).stack}` : "none"]),
t.p([t.strong("Parent offset: "), parentOffset]),
t.p([t.strong("Start: "), new Date(start).toString(), ` (${start})`]),
t.p([t.strong("Duration: "), `${itemDuration(item)}ms`]),
t.p([t.strong("Child count: "), itemChildren(item) ? `${itemChildren(item).length}` : "none"]),
t.p([t.strong("Forced finish: "), (itemForcedFinish(item) || false) + ""]),
t.p(t.strong("Values:")),
t.ul({class: "values"}, Object.entries(itemValues(item)).map(([key, value]) => {
let valueNode;
if (key === "ref") {
const refItem = itemByRef.get(value);
if (refItem) {
valueNode = t.a({href: `#${refItem.id}`}, itemCaption(refItem));
} else {
valueNode = `unknown ref ${value}`;
}
} else if (key === "refId") {
const refSources = itemsRefFrom.get(value) ?? [];
valueNode = t.div([t.p([`${value}`, t.br(),`Found these references:`]),t.ul(refSources.map(item => {
return t.li(t.a({href: `#${item.id}`}, itemCaption(item)));
}))]);
} else {
valueNode = stringifyItemValue(value);
}
return t.li([
t.span({className: "key"}, normalizeValueKey(key)),
t.span({className: "value"}, valueNode)
]);
})),
t.p(expandButton)
]);
document.querySelector("aside").replaceWith(aside);
}
function expandResursively(li) {
li.classList.add("expanded");
const ol = li.querySelector("ol");
if (ol) {
const len = ol.children.length;
for (let i = 0; i < len; i += 1) {
expandResursively(ol.children[i]);
}
}
}
document.getElementById("openFile").addEventListener("click", loadFile);
function getRootItemHeader(prevItem, item) {
if (prevItem) {
const diff = itemStart(item) - itemEnd(prevItem);
if (diff >= 0) {
return `+ ${formatTime(diff)}`;
} else {
const overlap = -diff;
if (overlap >= itemDuration(item)) {
return `ran entirely in parallel with`;
} else {
return `ran ${formatTime(-diff)} in parallel with`;
}
}
} else {
return new Date(itemStart(item)).toString();
}
}
async function loadFile() {
const file = await openFile();
document.getElementById("filename").innerText = file.name;
await loadBlob(file);
}
export async function loadBlob(blob) {
const json = await readFileAsText(blob);
const logs = JSON.parse(json);
logs.items.sort((a, b) => itemStart(a) - itemStart(b));
rootItem = {c: logs.items};
itemByRef = new Map();
itemsRefFrom = new Map();
preprocessRecursively(rootItem, null, itemByRef, itemsRefFrom, []);
const fragment = logs.items.reduce((fragment, item, i, items) => {
const prevItem = i === 0 ? null : items[i - 1];
fragment.appendChild(t.section([
t.h2(getRootItemHeader(prevItem, item)),
t.div({className: "timeline"}, t.ol(itemToNode(item, [i])))
]));
return fragment;
}, document.createDocumentFragment());
main.replaceChildren(fragment);
main.scrollTop = main.scrollHeight;
}
// TODO: make this use processRecursively
function preprocessRecursively(item, parentElement, refsMap, refsFromMap, path) {
item.s = (parentElement?.s || 0) + item.s;
if (itemRefSource(item)) {
refsMap.set(itemRefSource(item), item);
}
if (itemRef(item)) {
let refs = refsFromMap.get(itemRef(item));
if (!refs) {
refs = [];
refsFromMap.set(itemRef(item), refs);
}
refs.push(item);
}
if (itemChildren(item)) {
for (let i = 0; i < itemChildren(item).length; i += 1) {
// do it in advance for a child as we don't want to do it for the rootItem
const child = itemChildren(item)[i];
const childPath = path.concat(i);
child.id = childPath.join("/");
preprocessRecursively(child, item, refsMap, refsFromMap, childPath);
}
}
}
const MS_IN_SEC = 1000;
const MS_IN_MIN = MS_IN_SEC * 60;
const MS_IN_HOUR = MS_IN_MIN * 60;
const MS_IN_DAY = MS_IN_HOUR * 24;
function formatTime(ms) {
let str = "";
if (ms > MS_IN_DAY) {
const days = Math.floor(ms / MS_IN_DAY);
ms -= days * MS_IN_DAY;
str += `${days}d`;
}
if (ms > MS_IN_HOUR) {
const hours = Math.floor(ms / MS_IN_HOUR);
ms -= hours * MS_IN_HOUR;
str += `${hours}h`;
}
if (ms > MS_IN_MIN) {
const mins = Math.floor(ms / MS_IN_MIN);
ms -= mins * MS_IN_MIN;
str += `${mins}m`;
}
if (ms > MS_IN_SEC) {
const secs = ms / MS_IN_SEC;
str += `${secs.toFixed(2)}s`;
} else if (ms > 0 || !str.length) {
str += `${ms}ms`;
}
return str;
}
function itemChildren(item) { return item.c; }
function itemStart(item) { return item.s; }
function itemEnd(item) { return item.s + item.d; }
function itemDuration(item) { return item.d; }
function itemValues(item) { return item.v; }
function itemLevel(item) { return item.l; }
function itemLabel(item) { return item.v?.l; }
function itemType(item) { return item.v?.t; }
function itemError(item) { return item.e; }
function itemForcedFinish(item) { return item.f; }
function itemRef(item) { return item.v?.ref; }
function itemRefSource(item) { return item.v?.refId; }
function itemShortErrorMessage(item) {
if (itemError(item)) {
const e = itemError(item);
return e.name || e.stack.substr(0, e.stack.indexOf("\n"));
}
}
function itemCaption(item) {
if (itemLabel(item) && itemError(item)) {
return `${itemLabel(item)} (${itemShortErrorMessage(item)})`;
} if (itemType(item) === "network") {
return `${itemValues(item)?.method} ${itemValues(item)?.url}`;
} else if (itemLabel(item) && itemValues(item)?.id) {
return `${itemLabel(item)} ${itemValues(item).id}`;
} else if (itemLabel(item) && itemValues(item)?.status) {
return `${itemLabel(item)} (${itemValues(item).status})`;
} else if (itemLabel(item) && itemValues(item)?.type) {
return `${itemLabel(item)} (${itemValues(item)?.type})`;
} else if (itemRef(item)) {
const refItem = itemByRef.get(itemRef(item));
if (refItem) {
return `ref "${itemCaption(refItem)}"`
} else {
return `unknown ref ${itemRef(item)}`
}
} else {
return itemLabel(item) || itemType(item);
}
}
function normalizeValueKey(key) {
switch (key) {
case "t": return "type";
case "l": return "label";
default: return key;
}
}
// returns the node and the total range (recursively) occupied by the node
function itemToNode(item) {
const hasChildren = !!itemChildren(item)?.length;
const className = {
item: true,
"has-children": hasChildren,
error: itemError(item),
[`type-${itemType(item)}`]: !!itemType(item),
[`level-${itemLevel(item)}`]: true,
};
const id = item.id;
let captionNode;
if (itemRef(item)) {
const refItem = itemByRef.get(itemRef(item));
if (refItem) {
captionNode = ["ref ", t.a({href: `#${refItem.id}`}, itemCaption(refItem))];
}
}
if (!captionNode) {
captionNode = itemCaption(item);
}
const li = t.li([
t.div([
hasChildren ? t.button({className: "toggleExpanded"}) : "",
t.a({className, id, href: `#${id}`}, [
t.span({class: "caption"}, captionNode),
t.span({class: "duration"}, `(${formatTime(itemDuration(item))})`),
])
])
]);
if (itemChildren(item) && itemChildren(item).length) {
li.appendChild(t.ol(itemChildren(item).map(item => {
return itemToNode(item);
})));
}
return li;
}
const highlightForm = document.getElementById("highlightForm");
highlightForm.addEventListener("submit", evt => {
evt.preventDefault();
const matchesOutput = document.getElementById("highlightMatches");
const query = document.getElementById("highlight").value;
if (query) {
matchesOutput.innerText = "Searching…";
let matches = 0;
processRecursively(rootItem, item => {
let domNode = document.getElementById(item.id);
if (itemMatchesFilter(item, query)) {
matches += 1;
domNode.classList.add("highlighted");
domNode = domNode.parentElement;
while (domNode.nodeName !== "SECTION") {
if (domNode.nodeName === "LI") {
domNode.classList.add("expanded");
}
domNode = domNode.parentElement;
}
} else {
domNode.classList.remove("highlighted");
}
});
matchesOutput.innerText = `${matches} matches`;
} else {
for (const node of document.querySelectorAll(".highlighted")) {
node.classList.remove("highlighted");
}
matchesOutput.innerText = "";
}
});
function itemMatchesFilter(item, query) {
if (itemError(item)) {
if (valueMatchesQuery(itemError(item), query)) {
return true;
}
}
return valueMatchesQuery(itemValues(item), query);
}
function valueMatchesQuery(value, query) {
if (typeof value === "string") {
return value.includes(query);
} else if (typeof value === "object" && value !== null) {
for (const key in value) {
if (value.hasOwnProperty(key) && valueMatchesQuery(value[key], query)) {
return true;
}
}
} else if (typeof value === "number") {
return value.toString().includes(query);
}
return false;
}
function processRecursively(item, callback, parentItem) {
if (item.id) {
callback(item, parentItem);
}
if (itemChildren(item)) {
for (let i = 0; i < itemChildren(item).length; i += 1) {
// do it in advance for a child as we don't want to do it for the rootItem
const child = itemChildren(item)[i];
processRecursively(child, callback, item);
}
}
}
document.getElementById("collapseAll").addEventListener("click", () => {
for (const node of document.querySelectorAll(".expanded")) {
node.classList.remove("expanded");
}
});
document.getElementById("hideCollapsed").addEventListener("click", () => {
for (const node of document.querySelectorAll("section > div.timeline > ol > li:not(.expanded)")) {
node.closest("section").classList.add("hidden");
}
});
document.getElementById("hideHighlightedSiblings").addEventListener("click", () => {
for (const node of document.querySelectorAll(".highlighted")) {
const list = node.closest("ol");
const siblings = Array.from(list.querySelectorAll("li > div > a:not(.highlighted)")).map(n => n.closest("li"));
for (const sibling of siblings) {
if (!sibling.classList.contains("expanded")) {
sibling.classList.add("hidden");
}
}
}
});
document.getElementById("showAll").addEventListener("click", () => {
for (const node of document.querySelectorAll(".hidden")) {
node.classList.remove("hidden");
}
});

View file

@ -143,7 +143,7 @@ export class SettingsView extends TemplateView {
} }
async function openLogs(vm) { async function openLogs(vm) {
const logviewerUrl = (await import("../../../../../../scripts/logviewer/index.html?url")).default; const logviewerUrl = (await import("@matrixdotorg/structured-logviewer/index.html?url")).default;
const win = window.open(logviewerUrl); const win = window.open(logviewerUrl);
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
let shouldSendPings = true; let shouldSendPings = true;

View file

@ -56,6 +56,11 @@
version "3.2.3" version "3.2.3"
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4" resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4"
"@matrixdotorg/structured-logviewer@^0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@matrixdotorg/structured-logviewer/-/structured-logviewer-0.0.1.tgz#9c29470b552f874afbb1df16c6e8e9e0c55cbf59"
integrity sha512-IdPYxAFDEoEs2G1ImKCkCxFI3xF1DDctP3N9JOtHRvIPbPPdTT9DyNqKTewCb5zwjNB1mGBrnWyURnHDiOOL3w==
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"