Skip to content

Commit 0eb6c4f

Browse files
committed
initial commit
0 parents  commit 0eb6c4f

File tree

7 files changed

+1558
-0
lines changed

7 files changed

+1558
-0
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
node_modules
2+
.cache
3+
*.exe
4+
*.html
5+
*.js
6+
*.blob
7+
*.zip

LICENSE

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
BSD 3-Clause License
2+
3+
Copyright (c) 2025, Automatic Controls Equipment Systems, Inc.
4+
All rights reserved.
5+
6+
Redistribution and use in source and binary forms, with or without
7+
modification, are permitted provided that the following conditions are met:
8+
9+
1. Redistributions of source code must retain the above copyright notice, this
10+
list of conditions and the following disclaimer.
11+
12+
2. Redistributions in binary form must reproduce the above copyright notice,
13+
this list of conditions and the following disclaimer in the documentation
14+
and/or other materials provided with the distribution.
15+
16+
3. Neither the name of the copyright holder nor the names of its
17+
contributors may be used to endorse or promote products derived from
18+
this software without specific prior written permission.
19+
20+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# graphics-error-scraper
2+
3+
> WebCTRL is a trademark of Automated Logic Corporation. Any other trademarks mentioned herein are the property of their respective owners.
4+
5+
## Overview
6+
7+
**graphics-error-scraper** is a tool designed to automatically check whether any graphics in a WebCTRL system are displaying errors. This is especially useful for system administrators and operators who want to proactively monitor the health of their WebCTRL graphics. It has been tested on WebCTRL 8.0, 8.5, 9.0, and 10.0.
8+
9+
> **Note:** This project relies on [Puppeteer](https://pptr.dev/), a Node.js library that automates the Chrome browser. Chrome is used behind the scenes to interact with the WebCTRL system as a real user would.
10+
11+
## Installation
12+
13+
1. Download the latest release from the following link:
14+
[graphics-error-scraper.zip](https://github.com/automatic-controls/graphics-error-scraper/releases/latest/download/graphics-error-scraper.zip)
15+
- This will work only for 64-bit Windows OS.
16+
- For other operating systems, you are free to build from source.
17+
2. Unzip the contents to a convenient location on your computer.
18+
19+
## Usage
20+
21+
Run the tool from the command line with the following options:
22+
23+
```
24+
Usage: graphics-error-scraper [options]
25+
-u, --url <url> Target URL (required)
26+
-U, --username <username> Username (required)
27+
-p, --password <password> Password (required)
28+
-o, --output <file> Output file (default: errors.json, use - for stdout)
29+
-f, --force Overwrite output file if it exists
30+
-i, --ignoressl Ignore SSL certificate errors
31+
-t, --timeout <ms> Timeout for page actions in milliseconds (default: 180000)
32+
-h, --headless <bool> Headless mode (true/false, default: true)
33+
-v, --verbose Verbose logging (flag or "true")
34+
--version, -v Print version and exit
35+
```
36+
37+
> **Note:** For this tool to function correctly, the WebCTRL user account must have the **Automatically collapse trees** option unchecked in his or her settings.
38+
39+
### Example
40+
41+
```
42+
node index.js -u https://webctrl.example.com -U admin -p hvac1234 -o results.json
43+
```
44+
45+
- The tool will log in to the specified WebCTRL system, expand all geographic tree nodes, and check each graphic for errors.
46+
- Results are saved to the specified output file in JSON format, or printed to the console if `-o -` is used.

index.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
2+
import type * as puppeteerType from 'puppeteer';
3+
import type * as fsType from 'fs';
4+
const { createRequire } = require('module');
5+
const requireFromDisk = createRequire(__filename);
6+
process.env['PUPPETEER_CACHE_DIR'] = require('path').resolve(__dirname, '.cache/puppeteer');
7+
const puppeteer: typeof puppeteerType = requireFromDisk('puppeteer');
8+
const fs: typeof fsType = require('fs');
9+
10+
(async () => {
11+
// Simple argument parser for named flags
12+
const args = process.argv.slice(2);
13+
// Aliases for flags
14+
const flagAliases = Object.entries({
15+
'-u': 'url', '--url': 'url',
16+
'-U': 'username', '--username': 'username',
17+
'-p': 'password', '--password': 'password',
18+
'-o': 'output', '--output': 'output',
19+
'-h': 'headless', '--headless': 'headless',
20+
'-v': 'verbose', '--verbose': 'verbose',
21+
'-f': 'force', '--force': 'force',
22+
'-i': 'ignoressl', '--ignoressl': 'ignoressl',
23+
'-t': 'timeout', '--timeout': 'timeout'
24+
});
25+
26+
// Helper to get argument value by flag or alias
27+
const getArg = (name: string) => {
28+
const flags = flagAliases.filter(([_k, v]) => v === name).map(([k]) => k);
29+
for (const flag of flags) {
30+
const idx = args.findIndex(a => a === flag);
31+
if (idx !== -1 && idx + 1 < args.length) {
32+
const val = args[idx + 1];
33+
if (val === '-' || !val.startsWith('-')) {
34+
return val;
35+
}
36+
}
37+
}
38+
return undefined;
39+
};
40+
// Helper to check if a flag or alias is present
41+
const hasFlag = (name: string) => {
42+
const flags = flagAliases.filter(([_k, v]) => v === name).map(([k]) => k);
43+
return flags.some(flag => args.includes(flag));
44+
};
45+
46+
// Special case: -v or --version with no other parameters
47+
const onlyVersion = (args.length === 1 && (args[0] === '-v' || args[0] === '--version'));
48+
if (hasFlag('version') || onlyVersion) {
49+
console.log('v0.1.0');
50+
return;
51+
}
52+
53+
const url = getArg('url');
54+
const username = getArg('username');
55+
const password = getArg('password');
56+
const outputFile = getArg('output') || 'errors.json';
57+
const force = hasFlag('force');
58+
const headlessArg = getArg('headless');
59+
const verbose = hasFlag('verbose') || getArg('verbose') === 'true';
60+
61+
if (!url || !username || !password) {
62+
console.error(
63+
'Usage: graphics-error-scraper [options]\n' +
64+
' -u, --url <url> Target URL (required)\n' +
65+
' -U, --username <username> Username (required)\n' +
66+
' -p, --password <password> Password (required)\n' +
67+
' -o, --output <file> Output file (default: errors.json, use - for stdout)\n' +
68+
' -f, --force Overwrite output file if it exists\n' +
69+
' -i, --ignoressl Ignore SSL certificate errors\n' +
70+
' -t, --timeout <ms> Timeout for page actions in milliseconds (default: 180000)\n' +
71+
' -h, --headless <bool> Headless mode (true/false, default: true)\n' +
72+
' -v, --verbose Verbose logging (flag or "true")\n' +
73+
' --version, -v Print version and exit'
74+
);
75+
process.exit(1);
76+
}
77+
// headless: optional, defaults to true, accepts 'true' or 'false'
78+
const headless = headlessArg === undefined ? true : headlessArg.toLowerCase() === 'true';
79+
const ignoreSSL = hasFlag('ignoressl');
80+
const timeoutArg = getArg('timeout');
81+
const timeout = timeoutArg !== undefined && !isNaN(Number(timeoutArg)) ? Number(timeoutArg) : 180000;
82+
83+
// Helper for conditional logging
84+
const log = (...args: any[]) => { if (verbose) console.log(...args); };
85+
86+
// Browser setup
87+
const startTime = Date.now();
88+
log("Started: " + new Date().toLocaleString());
89+
log(`Navigating to ${url}`);
90+
const launchOpts: puppeteerType.LaunchOptions & Record<string, any> = headless
91+
? { headless: true }
92+
: { headless: false, defaultViewport: null, args: ['--start-maximized'] };
93+
if (ignoreSSL) {
94+
launchOpts.acceptInsecureCerts = true;
95+
if (launchOpts.args) {
96+
launchOpts.args.push('--disable-features=HttpsFirstBalancedModeAutoEnable');
97+
} else {
98+
launchOpts.args = ['--disable-features=HttpsFirstBalancedModeAutoEnable'];
99+
}
100+
}
101+
launchOpts.timeout = timeout;
102+
launchOpts.protocolTimeout = timeout;
103+
const browser = await puppeteer.launch(launchOpts);
104+
const [page] = await browser.pages();
105+
page.setDefaultTimeout(timeout);
106+
page.setDefaultNavigationTimeout(timeout);
107+
108+
// Promise-based wait function
109+
async function wait() {
110+
await new Promise(resolve => setTimeout(resolve, 1000));
111+
await page.waitForNetworkIdle();
112+
}
113+
114+
async function logout() {
115+
await page.locator('img[title="System Menu"]').click();
116+
await wait();
117+
await page.evaluate(() => {
118+
const x = (document.getElementById('rightMenuiframe') as HTMLIFrameElement)?.contentWindow?.document.getElementById('main_logout');
119+
if (x?.onmouseup) {
120+
x.onmouseup(new MouseEvent('mouseup', { bubbles: false }));
121+
}
122+
});
123+
await wait();
124+
await browser.close();
125+
process.exit(0);
126+
}
127+
128+
// Login
129+
await page.goto(url);
130+
log('Logging in...');
131+
await page.locator('#nameInput').fill(username);
132+
await page.locator('#pass').fill(password);
133+
await page.locator('#submit').click();
134+
await page.waitForNavigation();
135+
await wait();
136+
const navFrame = await (await (await (await page.mainFrame().$('#navTableFrame'))?.contentFrame())?.$('#navContent'))?.contentFrame();
137+
if (!navFrame) {
138+
console.error('Navigation frame not found. Possibly invalid credentials.');
139+
process.exit(3);
140+
}
141+
142+
// Expand all areas
143+
log("Expanding geographic tree nodes...");
144+
while (await page.evaluate(() => {
145+
let change = false;
146+
const doc = (document.getElementById('navTableFrame') as HTMLIFrameElement)?.contentWindow?.document.getElementById('navContent');
147+
const doc2 = (doc as HTMLIFrameElement)?.contentWindow?.document;
148+
if (!doc2) {
149+
return false;
150+
}
151+
for (const x of doc2.querySelectorAll('.TreeCtrl-twisty')) {
152+
if ((x as HTMLElement).getAttribute('src')?.includes('/clean_collapsed.png')) {
153+
const icon = x.parentElement?.querySelector('.TreeCtrl-content > img.TreeCtrl-icon');
154+
if (icon && (icon as HTMLElement).getAttribute('src')?.endsWith('/area.gif')) {
155+
(x as HTMLElement).click();
156+
change = true;
157+
}
158+
}
159+
}
160+
return change;
161+
})) {
162+
await wait();
163+
}
164+
165+
// Check for errors
166+
log("Checking for errors...");
167+
async function getDisplayPath(g: any) {
168+
return await g.evaluate((g: any) => {
169+
let p;
170+
let s = '';
171+
let q;
172+
while (p = g?.querySelector('.TreeCtrl-text')?.innerText) {
173+
s = p + (s ? ' / ' : '') + s;
174+
q = g?.parentElement?.parentElement?.parentElement?.parentElement?.querySelector('.TreeCtrl-content') ?? undefined;
175+
if (g === q) {
176+
break;
177+
}
178+
g = q;
179+
}
180+
return s;
181+
});
182+
}
183+
184+
const errors = [];
185+
for (const g of await navFrame.$$('.TreeCtrl-outer[id^=geoTree] .TreeCtrl-content')) {
186+
await g.click();
187+
await wait();
188+
if (await page.$('#actButtonSpan > span[title="View graphics"]') && await page.$('#errorIndication:not([style*="display: none"])')) {
189+
const e = await page.evaluate(() => {
190+
return {
191+
//@ts-ignore
192+
mainErrors: DisplayError.getMainErrors(),
193+
//@ts-ignore
194+
actionErrors: DisplayError.getActionErrors(),
195+
//@ts-ignore
196+
infoMessages: DisplayError.getInfoMessages()
197+
};
198+
});
199+
// Set url property to undefined if present in any error object
200+
const cleanList = (arr: any[]) => arr.map(obj => {
201+
if (obj && typeof obj === 'object' && 'url' in obj) {
202+
return { ...obj, url: undefined };
203+
}
204+
return obj;
205+
});
206+
const errorObj: any = { path: await getDisplayPath(g) };
207+
if (e.mainErrors && e.mainErrors.length > 0) errorObj.mainErrors = cleanList(e.mainErrors);
208+
if (e.actionErrors && e.actionErrors.length > 0) errorObj.actionErrors = cleanList(e.actionErrors);
209+
if (e.infoMessages && e.infoMessages.length > 0) errorObj.infoMessages = cleanList(e.infoMessages);
210+
errors.push(errorObj);
211+
}
212+
}
213+
if (outputFile === '-') {
214+
console.log(JSON.stringify(errors, null, 2));
215+
} else {
216+
if (!force && fs.existsSync(outputFile)) {
217+
console.error(`Output file '${outputFile}' already exists. Use --force or -f to overwrite.`);
218+
process.exit(2);
219+
}
220+
fs.writeFileSync(outputFile, JSON.stringify(errors, null, 2));
221+
log(`Results written to ${outputFile}`);
222+
}
223+
224+
// Logout
225+
log("Logging out...");
226+
await logout();
227+
log("Ended: " + new Date().toLocaleString());
228+
log(`Completed in ${((Date.now() - startTime) / 60000).toFixed(2)} minutes.`);
229+
})();

0 commit comments

Comments
 (0)