@assemblyline/al-control 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +3 -1
- package/package.json +5 -1
- package/src/cli/index.js +20 -5
- package/src/cli/lines/status.js +16 -13
- package/src/cli/workpieces/requeue.js +98 -0
- package/src/config.js +50 -4
- package/src/probe.js +217 -2
- package/src/utils/factory-data.js +10 -0
- package/src/utils/line-state.js +26 -125
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-10-19
|
|
11
|
+
|
|
12
|
+
The first npm release after 0.1.0; it includes 0.2.0.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Probe query `lines`: every line's stations in order, terminal, live bin counts, orphan counts
|
|
16
|
+
and summary, with no workpiece ids. The probe now holds the line model (station order,
|
|
17
|
+
terminal, summary); the CLI imports it instead of keeping a copy (decision 0006).
|
|
18
|
+
- `@assemblyline/al-control/probe` export, so an app that bundles the probe can vendor it and
|
|
19
|
+
byte-check its copy against a pinned version.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- `lines status` with no line uses the `lines` query: same output, a fraction of the data over
|
|
23
|
+
SSH.
|
|
24
|
+
|
|
25
|
+
## [0.2.0] - 2026-10-19
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- `-f <path>`: name a factory by its folder. `-f .` is the factory you're in, on this machine,
|
|
29
|
+
with no config needed; a configured local factory at that path keeps its name and `keep`
|
|
30
|
+
bins. With `--host`, the path is on that host.
|
|
31
|
+
- `workpieces requeue --bin STATION/failed` or `requeue <id>`: move failed workpieces (or one
|
|
32
|
+
failed or done workpiece) back to their station's input. Dry run unless `--yes`.
|
|
33
|
+
|
|
10
34
|
## [0.1.0] - 2026-10-19
|
|
11
35
|
|
|
12
36
|
First release as `@assemblyline/al-control`, forked from `fob-factory` 0.1.0 (FinOpsBricks).
|
package/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
The control room CLI for [Assembly Line](https://assemblyline.sh): watch and operate factories on this machine or on machines you reach over SSH. See what's in every bin, why workpieces failed, and start or stop factory sessions.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
+
al-control lines status -f . # the factory you're in
|
|
6
7
|
al-control hosts use build-box
|
|
7
8
|
al-control factories list
|
|
8
9
|
al-control lines status -f factory-globex
|
|
@@ -29,7 +30,7 @@ al-control workpieces errors # why things failed, on every factory
|
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
- **One config, one host at a time.** Like a kubectl context: `hosts use` picks the host, `--host` overrides it once.
|
|
32
|
-
- **Factories are named, never inferred.** Per-factory commands take `-f <name
|
|
33
|
+
- **Factories are named, never inferred.** Per-factory commands take `-f <name>` from the config, or a path: `-f .` is the factory you're standing in, and needs no config. The current directory is never used unless you type it.
|
|
33
34
|
- **Nothing installed on remote hosts.** A small read-only script is piped to the host's `node` over your own `ssh`, so `~/.ssh/config`, keys and Tailscale SSH all just work.
|
|
34
35
|
|
|
35
36
|
## Install
|
|
@@ -73,6 +74,7 @@ Factory paths are paths on that host. Remote hosts need key-based SSH (no passwo
|
|
|
73
74
|
| `workpieces list` / `show <id>` / `watch [id]` | yes | Where each workpiece is, its journey (and error, files), a live tail |
|
|
74
75
|
| `workpieces count [failed\|done\|finished]` / `errors` | optional | Failed, done and finished counts; failures grouped by error. All factories unless `-f` |
|
|
75
76
|
| `workpieces cat <id> <file>` / `open <id>` | yes | Read a file from a workpiece folder; open the folder (a shell in it, on a remote host) |
|
|
77
|
+
| `workpieces requeue --bin STATION/failed` / `requeue <id>` | yes | Move failed workpieces (or one failed or done workpiece) back to their station's input to run again. Dry run unless `--yes` |
|
|
76
78
|
| `workpieces clear --bin done\|finished` / `clear <id>` | yes | Delete done or finished workpieces, or one workpiece's copies; `--older-than <days>`. Dry run unless `--yes`; kept and conveyor-fed bins are skipped |
|
|
77
79
|
| `procs list` | | Every factory process on the host |
|
|
78
80
|
| `procs start` / `stop` / `restart` / `logs` | yes | pm2 control for one factory |
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assemblyline/al-control",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The control room CLI for Assembly Line: watch and operate factories across hosts, locally or over SSH",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"al-control": "./bin/al-control.js"
|
|
8
8
|
},
|
|
9
|
+
"exports": {
|
|
10
|
+
"./probe": "./src/probe.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
9
13
|
"scripts": {
|
|
10
14
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
11
15
|
"test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch"
|
package/src/cli/index.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Pattern: al-control <resource> <action> [target] [-f <factory>] [--host <host>]
|
|
5
5
|
*
|
|
6
6
|
* One host at a time (`hosts use`, or `--host` per command). Commands about a
|
|
7
|
-
* single factory require `-f
|
|
7
|
+
* single factory require `-f`: a configured name, or a path (`-f .` for the factory
|
|
8
|
+
* you're in). The current directory is never used unless you type it.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { readFileSync } from 'node:fs';
|
|
@@ -27,6 +28,7 @@ import { errorsHandler } from './workpieces/errors.js';
|
|
|
27
28
|
import { catHandler } from './workpieces/cat.js';
|
|
28
29
|
import { openHandler } from './workpieces/open.js';
|
|
29
30
|
import { clearHandler } from './workpieces/clear.js';
|
|
31
|
+
import { requeueHandler } from './workpieces/requeue.js';
|
|
30
32
|
import { listProcsHandler } from './procs/list.js';
|
|
31
33
|
import {
|
|
32
34
|
startFactoryHandler,
|
|
@@ -88,7 +90,7 @@ function withFactories(handler) {
|
|
|
88
90
|
function optionalFactoryOption(yargs) {
|
|
89
91
|
return localOptions(yargs).option('factory', {
|
|
90
92
|
alias: 'f',
|
|
91
|
-
describe: 'Only this factory (default: every factory on the host)',
|
|
93
|
+
describe: 'Only this factory: a name, or a path such as . (default: every factory on the host)',
|
|
92
94
|
type: 'string',
|
|
93
95
|
});
|
|
94
96
|
}
|
|
@@ -96,7 +98,7 @@ function optionalFactoryOption(yargs) {
|
|
|
96
98
|
function factoryOption(yargs) {
|
|
97
99
|
return localOptions(yargs).option('factory', {
|
|
98
100
|
alias: 'f',
|
|
99
|
-
describe: 'Factory name on the current host (required)',
|
|
101
|
+
describe: 'Factory: a name on the current host, or a path such as . (required)',
|
|
100
102
|
type: 'string',
|
|
101
103
|
});
|
|
102
104
|
}
|
|
@@ -217,7 +219,20 @@ export function run(args) {
|
|
|
217
219
|
}),
|
|
218
220
|
withFactory(clearHandler, { frame: false }),
|
|
219
221
|
)
|
|
220
|
-
.
|
|
222
|
+
.command(
|
|
223
|
+
'requeue [id]',
|
|
224
|
+
'Move failed workpieces (--bin STATION/failed), or one failed or done workpiece (id), back to their station\'s input. Dry run unless --yes.',
|
|
225
|
+
(y) => factoryOption(y.positional('id', { describe: 'One workpiece (exact or unique substring) in a failed or done bin', type: 'string' }))
|
|
226
|
+
.option('bin', { describe: 'Every workpiece in this failed bin (STATION/failed)', type: 'string' })
|
|
227
|
+
.option('yes', { describe: 'Actually move (default: dry run)', type: 'boolean' })
|
|
228
|
+
.check((a) => {
|
|
229
|
+
if (!a.id && !a.bin) throw new Error('Give --bin STATION/failed, or a workpiece id.');
|
|
230
|
+
if (a.id && a.bin) throw new Error('Give either --bin or a workpiece id, not both.');
|
|
231
|
+
return true;
|
|
232
|
+
}),
|
|
233
|
+
withFactory(requeueHandler, { frame: false }),
|
|
234
|
+
)
|
|
235
|
+
.demandCommand(1, 'Specify an action: list, show, watch, count, errors, cat, open, clear, requeue'))
|
|
221
236
|
.command('procs', 'Factory processes on the current host (pm2)', (yargs) => yargs
|
|
222
237
|
.usage('$0 procs <action> [-f <factory>]')
|
|
223
238
|
.command('list', 'Every running factory on the host', (y) => localOptions(y).option('json', jsonOption), withHost(listProcsHandler))
|
|
@@ -237,7 +252,7 @@ export function run(args) {
|
|
|
237
252
|
factories: ['list'],
|
|
238
253
|
lines: ['list', 'show', 'status'],
|
|
239
254
|
stations: ['status'],
|
|
240
|
-
workpieces: ['list', 'show', 'watch', 'count', 'errors', 'cat', 'open', 'clear'],
|
|
255
|
+
workpieces: ['list', 'show', 'watch', 'count', 'errors', 'cat', 'open', 'clear', 'requeue'],
|
|
241
256
|
procs: ['list', 'start', 'stop', 'restart', 'logs', 'monit'],
|
|
242
257
|
config: ['show'],
|
|
243
258
|
};
|
package/src/cli/lines/status.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { formatTable, formatHeader, formatField } from '../../utils/format.js';
|
|
2
2
|
import { summarizeLine, LIVE_BINS } from '../../utils/line-state.js';
|
|
3
|
-
import { loadLines } from '../../utils/factory-data.js';
|
|
3
|
+
import { loadLines, loadLineSummaries } from '../../utils/factory-data.js';
|
|
4
4
|
import { watchRender, DEFAULT_WATCH_INTERVAL_SECS } from '../../utils/watch-render.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -23,20 +23,25 @@ export async function statusLineHandler(argv, factory) {
|
|
|
23
23
|
}
|
|
24
24
|
const interval_secs = typeof interval === 'number' ? interval : DEFAULT_WATCH_INTERVAL_SECS;
|
|
25
25
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (json) console.log(JSON.stringify({}, null, 2));
|
|
31
|
-
else console.log(`No lines in ${factory.name}/.orchestrator/stations/. Run "al-orc stations pull --all" in the factory repo.`);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
26
|
+
const noLines = () => {
|
|
27
|
+
if (json) console.log(JSON.stringify({}, null, 2));
|
|
28
|
+
else console.log(`No lines in ${factory.name}/.orchestrator/stations/. Run "al-orc stations pull --all" in the factory repo.`);
|
|
29
|
+
};
|
|
34
30
|
|
|
31
|
+
const renderOnce = () => {
|
|
35
32
|
if (!line_arg) {
|
|
36
|
-
|
|
33
|
+
// Counts only: the probe computes each line's summary.
|
|
34
|
+
const { lines } = loadLineSummaries(factory);
|
|
35
|
+
if (Object.keys(lines).length === 0) return noLines();
|
|
36
|
+
const summaries = {};
|
|
37
|
+
for (const [code, line] of Object.entries(lines)) summaries[code] = line.summary;
|
|
38
|
+
renderLineSummary(summaries, json);
|
|
37
39
|
return;
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
const { lines } = loadLines(factory);
|
|
43
|
+
if (Object.keys(lines).length === 0) return noLines();
|
|
44
|
+
|
|
40
45
|
const ls = lines[line_arg];
|
|
41
46
|
if (!ls) {
|
|
42
47
|
const msg = `Line "${line_arg}" not found. Available: ${Object.keys(lines).sort().join(', ')}`;
|
|
@@ -58,9 +63,7 @@ export async function statusLineHandler(argv, factory) {
|
|
|
58
63
|
renderOnce();
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
function renderLineSummary(
|
|
62
|
-
const summaries = {};
|
|
63
|
-
for (const ls of Object.values(lines)) summaries[ls.code] = summarizeLine(ls);
|
|
66
|
+
function renderLineSummary(summaries, json) {
|
|
64
67
|
|
|
65
68
|
if (json) {
|
|
66
69
|
console.log(JSON.stringify(summaries, null, 2));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { probe } from '../../transport.js';
|
|
4
|
+
import { formatTable } from '../../utils/format.js';
|
|
5
|
+
import { loadLines } from '../../utils/factory-data.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `al-control workpieces requeue [id] --bin STATION/failed -f <factory> [--yes]` — move
|
|
9
|
+
* workpieces back to their station's `input` to run that station again. With `--bin`, every
|
|
10
|
+
* workpiece in that station's `failed` bin; with an id, that one workpiece from `failed` or
|
|
11
|
+
* `done`. Dry run unless `--yes`. The probe re-checks each folder, drops its stale
|
|
12
|
+
* `error.json`/`error.txt` and logs a `requeued` event; the station's next run picks it up.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} RequeueEntry
|
|
17
|
+
* @property {string} id
|
|
18
|
+
* @property {string} from - `STATION/BIN`
|
|
19
|
+
* @property {string} rel - folder relative to temp/stations/
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Which workpieces a requeue would move. Throws a user-facing Error when the request can't be
|
|
24
|
+
* met (unknown station, ambiguous id, nothing found).
|
|
25
|
+
*
|
|
26
|
+
* @param {Object<string, import('../../utils/line-state.js').LineState>} lines
|
|
27
|
+
* @param {{id?: string, bin?: string}} argv
|
|
28
|
+
* @returns {RequeueEntry[]}
|
|
29
|
+
*/
|
|
30
|
+
export function planRequeue(lines, argv) {
|
|
31
|
+
if (argv.bin) {
|
|
32
|
+
const m = argv.bin.match(/^([^/]+)\/failed\/?$/);
|
|
33
|
+
if (!m) throw new Error(`--bin takes STATION/failed (e.g. EX1/failed); to retry a done workpiece, give its id.`);
|
|
34
|
+
const station = m[1];
|
|
35
|
+
const line = Object.values(lines).find((l) => l.stations.includes(station));
|
|
36
|
+
if (!line) throw new Error(`Station "${station}" not found.`);
|
|
37
|
+
const map = line.bins[station].failed;
|
|
38
|
+
return [...(map ?? new Map())].map(([id, sub]) => ({ id, from: `${station}/failed`, rel: `${station}/failed/${sub}` }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @type {RequeueEntry[]} */
|
|
42
|
+
const all = [];
|
|
43
|
+
for (const line of Object.values(lines)) {
|
|
44
|
+
for (const station of line.stations) {
|
|
45
|
+
for (const bin of ['failed', 'done']) {
|
|
46
|
+
for (const [id, sub] of line.bins[station][bin] ?? new Map()) {
|
|
47
|
+
all.push({ id, from: `${station}/${bin}`, rel: `${station}/${bin}/${sub}` });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const query = /** @type {string} */ (argv.id);
|
|
53
|
+
const exact = all.filter((e) => e.id === query);
|
|
54
|
+
const ids = exact.length > 0 ? [query] : [...new Set(all.filter((e) => e.id.includes(query)).map((e) => e.id))].sort();
|
|
55
|
+
if (ids.length === 0) throw new Error(`No failed or done workpiece matches "${query}".`);
|
|
56
|
+
if (ids.length > 1) {
|
|
57
|
+
const shown = ids.slice(0, 10);
|
|
58
|
+
throw new Error(`"${query}" matches ${ids.length} workpieces; use the full id:\n ${shown.join('\n ')}${ids.length > 10 ? `\n … +${ids.length - 10} more` : ''}`);
|
|
59
|
+
}
|
|
60
|
+
const copies = all.filter((e) => e.id === ids[0]);
|
|
61
|
+
if (copies.length > 1) {
|
|
62
|
+
throw new Error(`${ids[0]} is in ${copies.map((c) => c.from).join(' and ')}; requeue one bin with --bin STATION/failed.`);
|
|
63
|
+
}
|
|
64
|
+
return copies;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {{id?: string, bin?: string, yes?: boolean}} argv
|
|
69
|
+
* @param {import('../../config.js').FactoryContext} factory
|
|
70
|
+
*/
|
|
71
|
+
export async function requeueHandler(argv, factory) {
|
|
72
|
+
const { lines } = loadLines(factory);
|
|
73
|
+
let plan;
|
|
74
|
+
try {
|
|
75
|
+
plan = planRequeue(lines, argv);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
const where = `${factory.host.name} / ${factory.name}`;
|
|
81
|
+
if (plan.length === 0) {
|
|
82
|
+
console.log(`Nothing to requeue: ${argv.bin} is empty on ${where}.`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const counts = new Map();
|
|
87
|
+
for (const e of plan) counts.set(e.from, (counts.get(e.from) ?? 0) + 1);
|
|
88
|
+
console.log(formatTable(['FROM', 'TO', 'WORKPIECES'], [...counts].map(([from, n]) => [from, `${from.split('/')[0]}/input`, String(n)])));
|
|
89
|
+
if (plan.length === 1) console.log(`\n${plan[0].id}`);
|
|
90
|
+
console.log('');
|
|
91
|
+
|
|
92
|
+
if (!argv.yes) {
|
|
93
|
+
console.log(`Dry run: would requeue ${plan.length} workpiece${plan.length === 1 ? '' : 's'} on ${where}. Re-run with --yes to move.`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const { requeued } = probe(factory.host, { type: 'requeue', path: factory.path, dirs: plan.map((e) => e.rel) });
|
|
97
|
+
console.log(`Requeued ${requeued.length} workpiece${requeued.length === 1 ? '' : 's'} on ${where}. The station's next run picks them up.`);
|
|
98
|
+
}
|
package/src/config.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
27
27
|
import { homedir } from 'node:os';
|
|
28
|
-
import { join } from 'node:path';
|
|
28
|
+
import { join, resolve, basename } from 'node:path';
|
|
29
29
|
import yaml from 'js-yaml';
|
|
30
30
|
|
|
31
31
|
export const LOCAL_HOST = 'local';
|
|
@@ -174,16 +174,62 @@ export function resolveHost(argv) {
|
|
|
174
174
|
*/
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
177
|
+
* Whether a `-f` value is a path rather than a configured name. Names never hold a `/`.
|
|
178
|
+
* @param {string} value
|
|
179
|
+
* @returns {boolean}
|
|
180
|
+
*/
|
|
181
|
+
export function isPathTarget(value) {
|
|
182
|
+
return value === '.' || value === '..' || value.startsWith('~') || value.includes('/');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {string} p */
|
|
186
|
+
function expandHome(p) {
|
|
187
|
+
return p === '~' || p.startsWith('~/') ? join(homedir(), p.slice(1)) : p;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* `-f <path>`: a factory named by its folder. Without `--host` the path is on this machine
|
|
192
|
+
* (relative to the current directory) and needs no config; a configured local factory at the
|
|
193
|
+
* same path lends its name and `keep` bins. With `--host`, the path is on that host as written.
|
|
194
|
+
* Typing the path is the explicit target decision 0003 asks for; nothing falls back to the cwd.
|
|
195
|
+
* @param {{host?: string, factory: string}} argv
|
|
196
|
+
* @returns {FactoryContext}
|
|
197
|
+
*/
|
|
198
|
+
function resolvePathTarget(argv) {
|
|
199
|
+
if (argv.host) {
|
|
200
|
+
const host = resolveHost(argv);
|
|
201
|
+
const name = basename(argv.factory.replace(/\/+$/, '')) || argv.factory;
|
|
202
|
+
host.factories = { ...host.factories, [name]: argv.factory };
|
|
203
|
+
host.keep = { ...host.keep, [name]: host.keep[name] ?? [] };
|
|
204
|
+
return { ...factoryContext(host, name, argv.host), flags: `-f ${argv.factory} --host ${argv.host}` };
|
|
205
|
+
}
|
|
206
|
+
const abs = resolve(expandHome(argv.factory));
|
|
207
|
+
let name = basename(abs);
|
|
208
|
+
let keep = [];
|
|
209
|
+
if (existsSync(configPath())) {
|
|
210
|
+
const local = loadConfig().hosts[LOCAL_HOST];
|
|
211
|
+
for (const [n, entry] of Object.entries(local?.factories ?? {})) {
|
|
212
|
+
const e = factoryEntry(entry);
|
|
213
|
+
if (resolve(expandHome(e.path)) === abs) ({ name, keep } = { name: n, keep: e.keep });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** @type {HostContext} */
|
|
217
|
+
const host = { name: LOCAL_HOST, is_local: true, factories: { [name]: abs }, keep: { [name]: keep } };
|
|
218
|
+
return { host, name, path: abs, keep, flags: `-f ${argv.factory}` };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolve host + the `-f` factory on it: a configured name, or a path (`-f .`). `-f` is always
|
|
223
|
+
* required — the current directory is never consulted on its own.
|
|
179
224
|
* @param {{host?: string, factory?: string}} argv
|
|
180
225
|
* @returns {FactoryContext}
|
|
181
226
|
*/
|
|
182
227
|
export function resolveFactory(argv) {
|
|
228
|
+
if (argv.factory && isPathTarget(argv.factory)) return resolvePathTarget({ ...argv, factory: argv.factory });
|
|
183
229
|
const host = resolveHost(argv);
|
|
184
230
|
const names = Object.keys(host.factories);
|
|
185
231
|
if (!argv.factory) {
|
|
186
|
-
throw new Error(`Specify a factory with -f <name
|
|
232
|
+
throw new Error(`Specify a factory with -f <name>, or -f . for the one you're in. Factories on ${host.name}: ${names.join(', ') || '(none)'}`);
|
|
187
233
|
}
|
|
188
234
|
const p = host.factories[argv.factory];
|
|
189
235
|
if (!p) {
|
package/src/probe.js
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
* can send this file verbatim over SSH and run it with `node -` on a host that
|
|
6
6
|
* has no al-control installed (see transport.js). Locally the client imports
|
|
7
7
|
* `runQuery` in-process. Either way the result is plain JSON — no Maps, no
|
|
8
|
-
* formatting.
|
|
8
|
+
* formatting.
|
|
9
|
+
*
|
|
10
|
+
* It also owns the line model (station order, terminal station, per-line
|
|
11
|
+
* summary) so every client renders the same answer from the `lines` query: the
|
|
12
|
+
* CLI imports these functions, and the control-room desktop app vendors this
|
|
13
|
+
* file byte for byte instead of porting the logic. Positions and formatting stay
|
|
9
14
|
* client-side (utils/line-state.js).
|
|
10
15
|
*
|
|
11
16
|
* Adapted from al-factory's station-files.js, line-state.js and
|
|
@@ -18,7 +23,8 @@ import os from 'node:os';
|
|
|
18
23
|
import path from 'node:path';
|
|
19
24
|
import { execSync } from 'node:child_process';
|
|
20
25
|
|
|
21
|
-
const ALL_BINS = ['input', 'doing', 'output', 'failed', 'done'];
|
|
26
|
+
export const ALL_BINS = ['input', 'doing', 'output', 'failed', 'done'];
|
|
27
|
+
export const LIVE_BINS = ['input', 'doing', 'output', 'failed'];
|
|
22
28
|
const MAX_BIN_DEPTH = 4; // depth limit for sub-bin walks (output/invoices/hi-1/ = depth 2)
|
|
23
29
|
const ERROR_MESSAGE_CAP = 2000;
|
|
24
30
|
const READ_FILE_CAP = 20 * 1024 * 1024;
|
|
@@ -503,6 +509,210 @@ function listRunningFactories() {
|
|
|
503
509
|
return { procs: rows, pm2_found: apps !== null };
|
|
504
510
|
}
|
|
505
511
|
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// Line model (pure; shared with the CLI and vendored by control-room)
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* @typedef {{line: string|null, data: any}} StationDef
|
|
518
|
+
*/
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Stable code for a station def — short_code, falling back to id.
|
|
522
|
+
* @param {StationDef} s
|
|
523
|
+
* @returns {string}
|
|
524
|
+
*/
|
|
525
|
+
export const codeOf = (s) => s.data.short_code || s.data.id;
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Index station defs by every reference form they answer to, so a dependency
|
|
529
|
+
* edge written as short_code or id finds its peer.
|
|
530
|
+
* @param {StationDef[]} members
|
|
531
|
+
* @returns {Map<string, StationDef>}
|
|
532
|
+
*/
|
|
533
|
+
export function indexStationsByRef(members) {
|
|
534
|
+
const by_ref = new Map();
|
|
535
|
+
for (const s of members) {
|
|
536
|
+
by_ref.set(codeOf(s), s);
|
|
537
|
+
if (s.data.id) by_ref.set(s.data.id, s);
|
|
538
|
+
if (s.data.short_code) by_ref.set(s.data.short_code, s);
|
|
539
|
+
}
|
|
540
|
+
return by_ref;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Normalise one `dependencies` entry (bare id string, or object with
|
|
545
|
+
* short_code/id) to a reference string.
|
|
546
|
+
* @param {any} dep
|
|
547
|
+
* @returns {string|undefined}
|
|
548
|
+
*/
|
|
549
|
+
export function dependencyRef(dep) {
|
|
550
|
+
return typeof dep === 'string' ? dep : dep?.short_code || dep?.id;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Resolve a station reference (short_code or orchestrator id) to its display
|
|
555
|
+
* code; unchanged when the peer isn't among `stations`.
|
|
556
|
+
* @param {string} ref
|
|
557
|
+
* @param {Map<string, StationDef>|StationDef[]} stations
|
|
558
|
+
* @returns {string}
|
|
559
|
+
*/
|
|
560
|
+
export function resolveStationRef(ref, stations) {
|
|
561
|
+
const by_ref = stations instanceof Map ? stations : indexStationsByRef(stations);
|
|
562
|
+
const match = by_ref.get(ref);
|
|
563
|
+
return match ? codeOf(match) : ref;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Topologically sort one line's station defs by `dependencies`, with an
|
|
568
|
+
* alphabetical pre-sort for stable sibling order.
|
|
569
|
+
* @param {StationDef[]} members
|
|
570
|
+
* @returns {StationDef[]}
|
|
571
|
+
*/
|
|
572
|
+
export function topoSortStations(members) {
|
|
573
|
+
const by_ref = indexStationsByRef(members);
|
|
574
|
+
/** @param {StationDef} s */
|
|
575
|
+
const depsOf = (s) => (s.data.dependencies || [])
|
|
576
|
+
.map(dependencyRef)
|
|
577
|
+
.map((/** @type {string} */ ref) => by_ref.get(ref))
|
|
578
|
+
.filter(Boolean);
|
|
579
|
+
|
|
580
|
+
const sorted = [...members].sort((a, b) => codeOf(a).localeCompare(codeOf(b)));
|
|
581
|
+
const visited = new Set();
|
|
582
|
+
/** @type {StationDef[]} */
|
|
583
|
+
const result = [];
|
|
584
|
+
/** @param {StationDef} s */
|
|
585
|
+
const visit = (s) => {
|
|
586
|
+
const code = codeOf(s);
|
|
587
|
+
if (visited.has(code)) return;
|
|
588
|
+
visited.add(code);
|
|
589
|
+
for (const dep of depsOf(s)) visit(dep);
|
|
590
|
+
result.push(s);
|
|
591
|
+
};
|
|
592
|
+
for (const s of sorted) visit(s);
|
|
593
|
+
return result;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Terminal = station no in-line peer depends on. With several sinks, the one
|
|
598
|
+
* latest in topo order.
|
|
599
|
+
* @param {StationDef[]} ordered - From topoSortStations
|
|
600
|
+
* @returns {string}
|
|
601
|
+
*/
|
|
602
|
+
export function computeTerminal(ordered) {
|
|
603
|
+
if (ordered.length === 0) return '';
|
|
604
|
+
const by_ref = indexStationsByRef(ordered);
|
|
605
|
+
const depended_on = new Set();
|
|
606
|
+
for (const s of ordered) {
|
|
607
|
+
for (const d of s.data.dependencies || []) {
|
|
608
|
+
const ref = dependencyRef(d);
|
|
609
|
+
if (ref && by_ref.has(ref)) depended_on.add(resolveStationRef(ref, by_ref));
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
613
|
+
const c = codeOf(ordered[i]);
|
|
614
|
+
if (!depended_on.has(c)) return c;
|
|
615
|
+
}
|
|
616
|
+
return codeOf(ordered[ordered.length - 1]);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Station defs grouped by line, each line's stations in dependency order.
|
|
621
|
+
* @param {StationDef[]} stations
|
|
622
|
+
* @returns {Object<string, {stations: string[], terminal: string}>}
|
|
623
|
+
*/
|
|
624
|
+
export function groupLines(stations) {
|
|
625
|
+
/** @type {Map<string, StationDef[]>} */
|
|
626
|
+
const grouped = new Map();
|
|
627
|
+
for (const def of stations) {
|
|
628
|
+
if (!def.line) continue;
|
|
629
|
+
if (!grouped.has(def.line)) grouped.set(def.line, []);
|
|
630
|
+
grouped.get(def.line)?.push(def);
|
|
631
|
+
}
|
|
632
|
+
/** @type {Object<string, {stations: string[], terminal: string}>} */
|
|
633
|
+
const lines = {};
|
|
634
|
+
for (const [code, members] of grouped) {
|
|
635
|
+
const ordered = topoSortStations(members);
|
|
636
|
+
lines[code] = { stations: ordered.map(codeOf), terminal: computeTerminal(ordered) };
|
|
637
|
+
}
|
|
638
|
+
return lines;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* @typedef {object} LineSummary
|
|
643
|
+
* @property {number} in_flight - input + doing + output, excluding terminal output
|
|
644
|
+
* @property {number} stuck - failed across the line
|
|
645
|
+
* @property {number} finished - terminal-station output
|
|
646
|
+
* @property {string[]} stuck_locations - e.g. '3 at VM2/failed'
|
|
647
|
+
* @property {{count: number, at: string|null}} biggest_flow - fullest in-flight bin
|
|
648
|
+
*/
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Per-line totals from live bin counts (`done` ignored).
|
|
652
|
+
* @param {{stations: string[], terminal: string}} line
|
|
653
|
+
* @param {Object<string, Object<string, number|null>>} counts - counts[station][bin], null when the bin is missing
|
|
654
|
+
* @returns {LineSummary}
|
|
655
|
+
*/
|
|
656
|
+
export function summarizeCounts(line, counts) {
|
|
657
|
+
let in_flight = 0;
|
|
658
|
+
let stuck = 0;
|
|
659
|
+
let finished = 0;
|
|
660
|
+
/** @type {string[]} */
|
|
661
|
+
const stuck_locations = [];
|
|
662
|
+
/** @type {{count: number, at: string|null}} */
|
|
663
|
+
let biggest_flow = { count: 0, at: null };
|
|
664
|
+
|
|
665
|
+
for (const station of line.stations) {
|
|
666
|
+
const is_terminal = station === line.terminal;
|
|
667
|
+
for (const bin of LIVE_BINS) {
|
|
668
|
+
const n = counts[station]?.[bin];
|
|
669
|
+
if (n === null || n === undefined) continue;
|
|
670
|
+
if (bin === 'failed') {
|
|
671
|
+
stuck += n;
|
|
672
|
+
if (n > 0) stuck_locations.push(`${n} at ${station}/failed`);
|
|
673
|
+
} else if (is_terminal && bin === 'output') {
|
|
674
|
+
finished += n;
|
|
675
|
+
} else {
|
|
676
|
+
in_flight += n;
|
|
677
|
+
if (n > biggest_flow.count) biggest_flow = { count: n, at: `${station}/${bin}` };
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return { in_flight, stuck, finished, stuck_locations, biggest_flow };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The `lines` query: every line with its stations in order, terminal, live bin
|
|
686
|
+
* counts and summary, from one read of the factory. Counts only, no workpiece
|
|
687
|
+
* ids, so it stays small enough to poll.
|
|
688
|
+
* @param {string} root
|
|
689
|
+
*/
|
|
690
|
+
function readLines(root) {
|
|
691
|
+
const { stations, lines: line_defs, factory, bins, orphans } = readState(root);
|
|
692
|
+
/** @type {Object<string, Object<string, number|null>>} */
|
|
693
|
+
const counts = {};
|
|
694
|
+
for (const [code, by_bin] of Object.entries(bins)) {
|
|
695
|
+
counts[code] = {};
|
|
696
|
+
for (const [bin, ids] of Object.entries(by_bin)) counts[code][bin] = ids === null ? null : Object.keys(ids).length;
|
|
697
|
+
}
|
|
698
|
+
const lines = {};
|
|
699
|
+
for (const [code, line] of Object.entries(groupLines(stations))) {
|
|
700
|
+
const def = line_defs[code] ?? {};
|
|
701
|
+
const line_counts = Object.fromEntries(line.stations.map((st) => [st, counts[st] ?? {}]));
|
|
702
|
+
lines[code] = {
|
|
703
|
+
code,
|
|
704
|
+
name: def.name ?? null,
|
|
705
|
+
location: def.location ?? null,
|
|
706
|
+
stations: line.stations,
|
|
707
|
+
terminal: line.terminal,
|
|
708
|
+
counts: line_counts,
|
|
709
|
+
orphans: Object.fromEntries(line.stations.filter((st) => orphans[st]).map((st) => [st, Object.keys(orphans[st]).length])),
|
|
710
|
+
summary: summarizeCounts(line, line_counts),
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
return { factory, lines };
|
|
714
|
+
}
|
|
715
|
+
|
|
506
716
|
// ---------------------------------------------------------------------------
|
|
507
717
|
// Entry points
|
|
508
718
|
// ---------------------------------------------------------------------------
|
|
@@ -510,6 +720,7 @@ function listRunningFactories() {
|
|
|
510
720
|
/**
|
|
511
721
|
* @typedef {{type: 'defs', path: string}
|
|
512
722
|
* | {type: 'state', path: string}
|
|
723
|
+
* | {type: 'lines', path: string}
|
|
513
724
|
* | {type: 'logs', path: string, dirs: string[]}
|
|
514
725
|
* | {type: 'errors', path: string, dirs: string[]}
|
|
515
726
|
* | {type: 'ages', path: string, dirs: string[]}
|
|
@@ -536,6 +747,10 @@ export function runQuery(query) {
|
|
|
536
747
|
const root = requireFactoryRoot(query.path);
|
|
537
748
|
return { root, ...readState(root) };
|
|
538
749
|
}
|
|
750
|
+
case 'lines': {
|
|
751
|
+
const root = requireFactoryRoot(query.path);
|
|
752
|
+
return { root, ...readLines(root) };
|
|
753
|
+
}
|
|
539
754
|
case 'logs': {
|
|
540
755
|
const root = requireFactoryRoot(query.path);
|
|
541
756
|
return { root, logs: readLogs(root, query.dirs) };
|
|
@@ -17,6 +17,16 @@ export function loadDefs(factory) {
|
|
|
17
17
|
return probe(factory.host, { type: 'defs', path: factory.path });
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Every line's station order, terminal, live bin counts and summary — the
|
|
22
|
+
* probe's `lines` query. Counts only, so it is cheap to poll over SSH.
|
|
23
|
+
* @param {import('../config.js').FactoryContext} factory
|
|
24
|
+
* @returns {{root: string, factory: object|null, lines: Object<string, {code: string, name: string|null, location: string|null, stations: string[], terminal: string, counts: Object<string, Object<string, number|null>>, orphans: Object<string, number>, summary: import('../probe.js').LineSummary}>}}
|
|
25
|
+
*/
|
|
26
|
+
export function loadLineSummaries(factory) {
|
|
27
|
+
return probe(factory.host, { type: 'lines', path: factory.path });
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
/**
|
|
21
31
|
* Live per-line state, plus the station defs it was built from and the
|
|
22
32
|
* orphaned failed folders (no pointer.json) per station.
|
package/src/utils/line-state.js
CHANGED
|
@@ -13,16 +13,29 @@
|
|
|
13
13
|
* - Position rule: walk stations terminal → source; at each, check
|
|
14
14
|
* output > doing > input > failed; first match wins. done ignored.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Station order, terminal and summaries come from ../probe.js (one copy, shared
|
|
17
|
+
* with control-room); this module adds the Map view and workpiece positions.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
import {
|
|
21
|
+
ALL_BINS,
|
|
22
|
+
LIVE_BINS,
|
|
23
|
+
codeOf,
|
|
24
|
+
indexStationsByRef,
|
|
25
|
+
dependencyRef,
|
|
26
|
+
resolveStationRef,
|
|
27
|
+
topoSortStations,
|
|
28
|
+
computeTerminal,
|
|
29
|
+
groupLines,
|
|
30
|
+
summarizeCounts,
|
|
31
|
+
} from '../probe.js';
|
|
32
|
+
|
|
33
|
+
export { ALL_BINS, LIVE_BINS, codeOf, indexStationsByRef, dependencyRef, resolveStationRef, topoSortStations, computeTerminal };
|
|
34
|
+
|
|
22
35
|
const LIVE_PRIORITY = ['output', 'doing', 'input', 'failed']; // most-advanced first
|
|
23
36
|
|
|
24
37
|
/**
|
|
25
|
-
* @typedef {
|
|
38
|
+
* @typedef {import('../probe.js').StationDef} StationDef
|
|
26
39
|
*/
|
|
27
40
|
|
|
28
41
|
/**
|
|
@@ -46,119 +59,23 @@ const LIVE_PRIORITY = ['output', 'doing', 'input', 'failed']; // most-advanced f
|
|
|
46
59
|
* @property {boolean} [anomaly] - true when only present in done (not live)
|
|
47
60
|
*/
|
|
48
61
|
|
|
49
|
-
/** Stable code for a station def — short_code, falling back to id. */
|
|
50
|
-
export const codeOf = (s) => s.data.short_code || s.data.id;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Index station defs by every reference form they answer to, so a dependency
|
|
54
|
-
* edge written as short_code or id finds its peer.
|
|
55
|
-
* @param {StationDef[]} members
|
|
56
|
-
* @returns {Map<string, StationDef>}
|
|
57
|
-
*/
|
|
58
|
-
export function indexStationsByRef(members) {
|
|
59
|
-
const by_ref = new Map();
|
|
60
|
-
for (const s of members) {
|
|
61
|
-
by_ref.set(codeOf(s), s);
|
|
62
|
-
if (s.data.id) by_ref.set(s.data.id, s);
|
|
63
|
-
if (s.data.short_code) by_ref.set(s.data.short_code, s);
|
|
64
|
-
}
|
|
65
|
-
return by_ref;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Normalise one `dependencies` entry (bare id string, or object with
|
|
70
|
-
* short_code/id) to a reference string.
|
|
71
|
-
*/
|
|
72
|
-
export function dependencyRef(dep) {
|
|
73
|
-
return typeof dep === 'string' ? dep : dep?.short_code || dep?.id;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Resolve a station reference (short_code or orchestrator id) to its display
|
|
78
|
-
* code; unchanged when the peer isn't among `stations`.
|
|
79
|
-
* @param {string} ref
|
|
80
|
-
* @param {Map<string, StationDef>|StationDef[]} stations
|
|
81
|
-
*/
|
|
82
|
-
export function resolveStationRef(ref, stations) {
|
|
83
|
-
const by_ref = stations instanceof Map ? stations : indexStationsByRef(stations);
|
|
84
|
-
const match = by_ref.get(ref);
|
|
85
|
-
return match ? codeOf(match) : ref;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Topologically sort one line's station defs by `dependencies`, with an
|
|
90
|
-
* alphabetical pre-sort for stable sibling order.
|
|
91
|
-
* @param {StationDef[]} members
|
|
92
|
-
* @returns {StationDef[]}
|
|
93
|
-
*/
|
|
94
|
-
export function topoSortStations(members) {
|
|
95
|
-
const by_ref = indexStationsByRef(members);
|
|
96
|
-
const depsOf = (s) => (s.data.dependencies || [])
|
|
97
|
-
.map(dependencyRef)
|
|
98
|
-
.map((ref) => by_ref.get(ref))
|
|
99
|
-
.filter(Boolean);
|
|
100
|
-
|
|
101
|
-
const sorted = [...members].sort((a, b) => codeOf(a).localeCompare(codeOf(b)));
|
|
102
|
-
const visited = new Set();
|
|
103
|
-
const result = [];
|
|
104
|
-
const visit = (s) => {
|
|
105
|
-
const code = codeOf(s);
|
|
106
|
-
if (visited.has(code)) return;
|
|
107
|
-
visited.add(code);
|
|
108
|
-
for (const dep of depsOf(s)) visit(dep);
|
|
109
|
-
result.push(s);
|
|
110
|
-
};
|
|
111
|
-
for (const s of sorted) visit(s);
|
|
112
|
-
return result;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Terminal = station no in-line peer depends on. With several sinks, the one
|
|
117
|
-
* latest in topo order.
|
|
118
|
-
*/
|
|
119
|
-
function computeTerminal(ordered) {
|
|
120
|
-
if (ordered.length === 0) return '';
|
|
121
|
-
const by_ref = indexStationsByRef(ordered);
|
|
122
|
-
const depended_on = new Set();
|
|
123
|
-
for (const s of ordered) {
|
|
124
|
-
for (const d of s.data.dependencies || []) {
|
|
125
|
-
const ref = dependencyRef(d);
|
|
126
|
-
if (by_ref.has(ref)) depended_on.add(resolveStationRef(ref, by_ref));
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
for (let i = ordered.length - 1; i >= 0; i--) {
|
|
130
|
-
const c = codeOf(ordered[i]);
|
|
131
|
-
if (!depended_on.has(c)) return c;
|
|
132
|
-
}
|
|
133
|
-
return codeOf(ordered[ordered.length - 1]);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
62
|
/**
|
|
137
63
|
* Build per-line state from a probe `state` result.
|
|
138
64
|
* @param {{stations: StationDef[], bins: Object<string, Object<string, Object<string, string>|null>>}} state
|
|
139
65
|
* @returns {Object<string, LineState>} keyed by line code
|
|
140
66
|
*/
|
|
141
67
|
export function buildLineState(state) {
|
|
142
|
-
const grouped = new Map();
|
|
143
|
-
for (const def of state.stations) {
|
|
144
|
-
if (!def.line) continue;
|
|
145
|
-
if (!grouped.has(def.line)) grouped.set(def.line, []);
|
|
146
|
-
grouped.get(def.line).push(def);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
68
|
const lines = {};
|
|
150
|
-
for (const [line_code,
|
|
151
|
-
const ordered = topoSortStations(members);
|
|
152
|
-
const station_codes = ordered.map(codeOf);
|
|
69
|
+
for (const [line_code, line] of Object.entries(groupLines(state.stations))) {
|
|
153
70
|
const bins = {};
|
|
154
|
-
for (const station of
|
|
71
|
+
for (const station of line.stations) {
|
|
155
72
|
bins[station] = {};
|
|
156
73
|
for (const bin of ALL_BINS) {
|
|
157
74
|
const listing = state.bins[station]?.[bin];
|
|
158
75
|
bins[station][bin] = listing ? new Map(Object.entries(listing)) : null;
|
|
159
76
|
}
|
|
160
77
|
}
|
|
161
|
-
lines[line_code] = { code: line_code, stations:
|
|
78
|
+
lines[line_code] = { code: line_code, stations: line.stations, terminal: line.terminal, bins };
|
|
162
79
|
}
|
|
163
80
|
return lines;
|
|
164
81
|
}
|
|
@@ -296,29 +213,13 @@ export function workpieceRelDir(pos, workpiece_id) {
|
|
|
296
213
|
* @param {LineState} line
|
|
297
214
|
*/
|
|
298
215
|
export function summarizeLine(line) {
|
|
299
|
-
|
|
300
|
-
let stuck = 0;
|
|
301
|
-
let finished = 0;
|
|
302
|
-
const stuck_locations = [];
|
|
303
|
-
let biggest_flow = { count: 0, at: null };
|
|
304
|
-
|
|
216
|
+
const counts = {};
|
|
305
217
|
for (const station of line.stations) {
|
|
306
|
-
|
|
307
|
-
const is_terminal = station === line.terminal;
|
|
218
|
+
counts[station] = {};
|
|
308
219
|
for (const bin of LIVE_BINS) {
|
|
309
|
-
const map = bins[bin];
|
|
310
|
-
|
|
311
|
-
const n = map.size;
|
|
312
|
-
if (bin === 'failed') {
|
|
313
|
-
stuck += n;
|
|
314
|
-
if (n > 0) stuck_locations.push(`${n} at ${station}/failed`);
|
|
315
|
-
} else if (is_terminal && bin === 'output') {
|
|
316
|
-
finished += n;
|
|
317
|
-
} else {
|
|
318
|
-
in_flight += n;
|
|
319
|
-
if (n > biggest_flow.count) biggest_flow = { count: n, at: `${station}/${bin}` };
|
|
320
|
-
}
|
|
220
|
+
const map = line.bins[station][bin];
|
|
221
|
+
counts[station][bin] = map ? map.size : null;
|
|
321
222
|
}
|
|
322
223
|
}
|
|
323
|
-
return
|
|
224
|
+
return summarizeCounts(line, counts);
|
|
324
225
|
}
|