attesso 0.8.0 → 0.9.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/README.md +15 -0
- package/bin/attesso.mjs +33 -1
- package/package.json +1 -1
- package/src/connect.mjs +30 -22
- package/src/remote-policy.mjs +142 -0
- package/src/setup.mjs +6 -4
- package/src/ui/static/index.html +57 -40
package/README.md
CHANGED
|
@@ -45,6 +45,21 @@ npx attesso sync
|
|
|
45
45
|
npx attesso sync --watch
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
## Hosted policy
|
|
49
|
+
|
|
50
|
+
A hosted workspace can publish its portable rule set from the dashboard. The
|
|
51
|
+
local engine fetches the signed bundle, verifies it against Attesso's published
|
|
52
|
+
JWKS and only then writes `.attesso/rules.json`; path scope stays local.
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
export ATTESSO_SYNC_URL=https://api.attesso.com
|
|
56
|
+
export ATTESSO_API_KEY=att_live_...
|
|
57
|
+
npx attesso policy pull --apply
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Without `--apply` the command verifies and previews the version and digest. A
|
|
61
|
+
tampered signature, an unknown key or an invalid rule set writes nothing.
|
|
62
|
+
|
|
48
63
|
The API key must belong to an entitled live workspace. A retry after a failed
|
|
49
64
|
request is safe: the server deduplicates by device and sequence, and the local
|
|
50
65
|
cursor does not move until the batch is accepted. `--watch` repeats every five
|
package/bin/attesso.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { CAPABILITIES, TRUST_LEVELS, validateRules } from './../src/rules.mjs';
|
|
|
30
30
|
import { validatePathsConfig } from './../src/fs-paths.mjs';
|
|
31
31
|
import { startUi } from './../src/ui/server.mjs';
|
|
32
32
|
import { sync, watchSync } from './../src/sync.mjs';
|
|
33
|
+
import { pullPolicy } from './../src/remote-policy.mjs';
|
|
33
34
|
|
|
34
35
|
const argv = process.argv.slice(2);
|
|
35
36
|
const dirAt = argv.indexOf('--dir');
|
|
@@ -71,12 +72,15 @@ const USAGE = `attesso: a local, signed ledger of what your agents attempted
|
|
|
71
72
|
attesso hook [--observe] harness hook: one tool call on stdin, native refusal on stdout
|
|
72
73
|
attesso setup [--write] [--enforce] [--dry-run]
|
|
73
74
|
detect a harness, wire the hook, write the agent block
|
|
74
|
-
attesso connect [--write] [--mode observe|enforce|off] [--json]
|
|
75
|
+
attesso connect [--write] [--mode observe|enforce|off] [--harness KEY] [--json]
|
|
75
76
|
self-service: init, mode, hook and AGENTS.md
|
|
76
77
|
attesso status [--json] connection, mode and wired harnesses
|
|
77
78
|
attesso mode [observe|enforce|off] read or set the active hook mode
|
|
78
79
|
attesso paths [--json|--set FILE] read or replace the filesystem scope
|
|
79
80
|
attesso ui [--port N] [--no-open] local control surface for the signed ledger
|
|
81
|
+
attesso policy pull [--url URL] [--apply] [--json]
|
|
82
|
+
verify the hosted rule bundle and optionally
|
|
83
|
+
write rules.json (key from ATTESSO_API_KEY)
|
|
80
84
|
attesso sync [--url URL] [--limit N] [--json] [--dry-run] [--watch]
|
|
81
85
|
send the next decisions to the hosted mirror
|
|
82
86
|
--watch keeps it current every 5 seconds
|
|
@@ -625,6 +629,34 @@ switch (command) {
|
|
|
625
629
|
}
|
|
626
630
|
break;
|
|
627
631
|
}
|
|
632
|
+
case 'policy': {
|
|
633
|
+
try {
|
|
634
|
+
const options = flags(rest);
|
|
635
|
+
const subcommand = rest.find((value) => !value.startsWith('--'));
|
|
636
|
+
if (subcommand !== 'pull') throw new Error('policy takes pull');
|
|
637
|
+
requireLedger();
|
|
638
|
+
const url = typeof options.url === 'string' ? options.url : process.env.ATTESSO_SYNC_URL;
|
|
639
|
+
const key = typeof options.key === 'string' ? options.key : process.env.ATTESSO_API_KEY;
|
|
640
|
+
const result = await pullPolicy({
|
|
641
|
+
dir: DIR,
|
|
642
|
+
url,
|
|
643
|
+
key,
|
|
644
|
+
apply: rest.includes('--apply'),
|
|
645
|
+
});
|
|
646
|
+
if (options.json) {
|
|
647
|
+
console.log(JSON.stringify(result, null, 2));
|
|
648
|
+
} else {
|
|
649
|
+
console.log(`policy: v${result.version} verified with ${result.kid}`);
|
|
650
|
+
console.log(`digest: ${result.digest}`);
|
|
651
|
+
if (result.applied) console.log(`rules: ${result.changed ? 'written' : 'already current'} (${paths(DIR).rules})`);
|
|
652
|
+
else console.log('preview only; re-run with --apply to write rules.json');
|
|
653
|
+
}
|
|
654
|
+
} catch (error) {
|
|
655
|
+
console.error(`policy: ${error.message}`);
|
|
656
|
+
process.exit(2);
|
|
657
|
+
}
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
628
660
|
case 'sync': {
|
|
629
661
|
try {
|
|
630
662
|
const options = flags(rest);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "attesso",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Bounds: decide an AI agent's next tool call before it runs, with a signed record. Free and unlimited locally, $0.00085 per synced decision.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/connect.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// harness hook, and report a machine-readable result on stdout.
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
7
|
import { detectHarnesses, writeAgentContext } from './setup.mjs';
|
|
8
8
|
import { wireHarness } from './install.mjs';
|
|
9
9
|
import { init, isInitialised, paths } from './store.mjs';
|
|
@@ -57,20 +57,24 @@ function legacyModeDirs(home) {
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
const text = readFileSync(path, 'utf8');
|
|
64
|
-
return /attesso[^\n"]*hook/.test(text);
|
|
65
|
-
} catch {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function wiredHarnesses(harnesses) {
|
|
60
|
+
function wiredHarnesses(harnesses, { dir, cwd }) {
|
|
61
|
+
const ledger = resolve(dir);
|
|
71
62
|
return harnesses.map((harness) => {
|
|
72
63
|
const candidate = harness.key === 'cline' ? join(harness.path, 'PreToolUse') : harness.path;
|
|
73
|
-
|
|
64
|
+
let wired = false;
|
|
65
|
+
if (existsSync(candidate)) {
|
|
66
|
+
try {
|
|
67
|
+
const text = readFileSync(candidate, 'utf8');
|
|
68
|
+
// A hook for a different ledger is not this project's connection. The
|
|
69
|
+
// legacy wrapper has no --dir, so it keeps counting as wired.
|
|
70
|
+
const match = text.match(/--dir\s+(?:'([^']+)'|"([^"]+)"|(\S+))/);
|
|
71
|
+
const target = match ? match[1] || match[2] || match[3] : null;
|
|
72
|
+
wired = target ? resolve(cwd, target) === ledger : /attesso[^\n"]*hook/.test(text);
|
|
73
|
+
} catch {
|
|
74
|
+
wired = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { ...harness, wired };
|
|
74
78
|
});
|
|
75
79
|
}
|
|
76
80
|
|
|
@@ -86,7 +90,7 @@ function instructions({ mode, wired, manual }) {
|
|
|
86
90
|
export function status({ dir, cwd = process.cwd(), home = homedir() } = {}) {
|
|
87
91
|
const initialized = isInitialised(dir);
|
|
88
92
|
const mode = readMode(dir);
|
|
89
|
-
const detected = wiredHarnesses(detectHarnesses({ home, cwd }));
|
|
93
|
+
const detected = wiredHarnesses(detectHarnesses({ home, cwd }), { dir, cwd });
|
|
90
94
|
const wired = detected.filter((harness) => harness.wired);
|
|
91
95
|
const manual = detected.filter((harness) => !['hermes', 'claude-code', 'codex', 'gemini', 'cline'].includes(harness.key));
|
|
92
96
|
return {
|
|
@@ -113,8 +117,12 @@ export function connect({
|
|
|
113
117
|
if (!CONNECT_MODES.includes(mode)) {
|
|
114
118
|
throw new Error(`invalid mode '${mode}'; expected ${CONNECT_MODES.join(', ')}`);
|
|
115
119
|
}
|
|
116
|
-
|
|
117
|
-
|
|
120
|
+
// Harness configs are global. A relative --dir becomes a hook that points at
|
|
121
|
+
// whatever .attesso happens to be in the agent's current directory, so the
|
|
122
|
+
// ledger path must be absolute before it is written anywhere.
|
|
123
|
+
const ledgerDir = resolve(dir);
|
|
124
|
+
const initialized = isInitialised(ledgerDir);
|
|
125
|
+
if (write && !initialized) init(ledgerDir);
|
|
118
126
|
const detected = detectHarnesses({ home, cwd }).filter((harness) => !only || harness.key === only);
|
|
119
127
|
const agentContext = writeAgentContext(cwd, { dryRun: !write });
|
|
120
128
|
const results = [];
|
|
@@ -130,7 +138,7 @@ export function connect({
|
|
|
130
138
|
continue;
|
|
131
139
|
}
|
|
132
140
|
try {
|
|
133
|
-
const result = wireHarness(harness.key, cli,
|
|
141
|
+
const result = wireHarness(harness.key, cli, ledgerDir, {
|
|
134
142
|
dryRun: !write,
|
|
135
143
|
enforce: mode === 'enforce',
|
|
136
144
|
modeFile: true,
|
|
@@ -143,14 +151,14 @@ export function connect({
|
|
|
143
151
|
results.push({ key: harness.key, path: harness.path, wired: false, error: error.message });
|
|
144
152
|
}
|
|
145
153
|
}
|
|
146
|
-
if (write) setModeAll(
|
|
154
|
+
if (write) setModeAll(ledgerDir, mode, { home });
|
|
147
155
|
const wired = results.filter((result) => result.applied || result.reason === 'already wired');
|
|
148
|
-
const effectiveMode = write ? mode : readMode(
|
|
156
|
+
const effectiveMode = write ? mode : readMode(ledgerDir);
|
|
149
157
|
return {
|
|
150
|
-
initialized: isInitialised(
|
|
158
|
+
initialized: isInitialised(ledgerDir),
|
|
151
159
|
mode: effectiveMode,
|
|
152
|
-
connected: isInitialised(
|
|
153
|
-
ledger: paths(
|
|
160
|
+
connected: isInitialised(ledgerDir) && effectiveMode !== null && effectiveMode !== 'off' && wired.length > 0,
|
|
161
|
+
ledger: paths(ledgerDir).dir,
|
|
154
162
|
harnesses: results,
|
|
155
163
|
wired: wired.map((result) => result.key),
|
|
156
164
|
manual: results.filter((result) => result.manual || result.error || result.reason?.includes('merge by hand')).map((result) => result.key),
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Hosted policy distribution: fetch a signed rule bundle, verify it against the
|
|
2
|
+
// published Attesso JWKS, and only then let the local engine write rules.json.
|
|
3
|
+
import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
|
|
4
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { paths } from './store.mjs';
|
|
6
|
+
import { validateRules } from './rules.mjs';
|
|
7
|
+
|
|
8
|
+
export const POLICY_FORMAT = 'attesso.bounds.policy.v1';
|
|
9
|
+
|
|
10
|
+
const fromB64url = (value) => {
|
|
11
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
12
|
+
return Buffer.from(normalized + '='.repeat((4 - (normalized.length % 4)) % 4), 'base64');
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function policyEndpoint(base) {
|
|
16
|
+
const parsed = new URL(base);
|
|
17
|
+
if (parsed.protocol !== 'https:' && parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') {
|
|
18
|
+
throw new Error('policy url must use HTTPS, except on localhost');
|
|
19
|
+
}
|
|
20
|
+
const path = parsed.pathname.replace(/\/+$/, '').replace(/\/v1\/bounds\/(sync|policy)$/, '');
|
|
21
|
+
parsed.pathname = `${path}/v1/bounds/policy`;
|
|
22
|
+
parsed.search = '';
|
|
23
|
+
parsed.hash = '';
|
|
24
|
+
return parsed.toString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function jwksEndpoint(base) {
|
|
28
|
+
return new URL('/.well-known/jwks.json', new URL(base)).toString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function pullPolicy({
|
|
32
|
+
dir,
|
|
33
|
+
url,
|
|
34
|
+
key,
|
|
35
|
+
apply = false,
|
|
36
|
+
fetchImpl = fetch,
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!url) throw new Error('policy url is required');
|
|
39
|
+
if (!key) throw new Error('policy API key is required');
|
|
40
|
+
const response = await fetchImpl(policyEndpoint(url), {
|
|
41
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
42
|
+
signal: AbortSignal.timeout(20_000),
|
|
43
|
+
});
|
|
44
|
+
if (response.status === 404) throw new Error('no hosted policy is published');
|
|
45
|
+
if (!response.ok) throw new Error(`policy fetch failed with status ${response.status}`);
|
|
46
|
+
let bundle;
|
|
47
|
+
try {
|
|
48
|
+
bundle = await response.json();
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error('policy response is invalid');
|
|
51
|
+
}
|
|
52
|
+
if (!bundle || typeof bundle !== 'object' || !bundle.envelope || typeof bundle.envelope !== 'object') {
|
|
53
|
+
throw new Error('policy response is missing its signature');
|
|
54
|
+
}
|
|
55
|
+
if (bundle.envelope.format !== POLICY_FORMAT) {
|
|
56
|
+
throw new Error('policy response has an unexpected format');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const jwksResponse = await fetchImpl(jwksEndpoint(url), { signal: AbortSignal.timeout(20_000) });
|
|
60
|
+
if (!jwksResponse.ok) throw new Error(`policy key fetch failed with status ${jwksResponse.status}`);
|
|
61
|
+
let jwks;
|
|
62
|
+
try {
|
|
63
|
+
jwks = await jwksResponse.json();
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error('policy key response is invalid');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const verified = verifyPolicyEnvelope(bundle.envelope, jwks);
|
|
69
|
+
const changed = !existsSync(paths(dir).rules) ||
|
|
70
|
+
JSON.stringify(JSON.parse(readFileSync(paths(dir).rules, 'utf8'))) !== JSON.stringify(verified.rules);
|
|
71
|
+
if (apply) writeRules(dir, verified.rules);
|
|
72
|
+
return {
|
|
73
|
+
version: verified.version,
|
|
74
|
+
kid: verified.kid,
|
|
75
|
+
digest: verified.digest,
|
|
76
|
+
applied: apply,
|
|
77
|
+
changed,
|
|
78
|
+
rules: verified.rules,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function verifyPolicyEnvelope(envelope, jwks) {
|
|
83
|
+
if (!envelope || typeof envelope !== 'object') throw new Error('policy envelope is invalid');
|
|
84
|
+
if (envelope.format !== POLICY_FORMAT) throw new Error('policy envelope has an unexpected format');
|
|
85
|
+
for (const field of ['protected', 'payload', 'signature']) {
|
|
86
|
+
if (typeof envelope[field] !== 'string' || envelope[field] === '') {
|
|
87
|
+
throw new Error('policy envelope is incomplete');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let header;
|
|
91
|
+
try {
|
|
92
|
+
header = JSON.parse(fromB64url(envelope.protected).toString('utf8'));
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error('policy envelope header is invalid');
|
|
95
|
+
}
|
|
96
|
+
if (header.alg !== 'ES256' || typeof header.kid !== 'string') {
|
|
97
|
+
throw new Error('policy envelope must use an ES256 key');
|
|
98
|
+
}
|
|
99
|
+
const key = Array.isArray(jwks?.keys) ? jwks.keys.find((item) => item.kid === header.kid) : undefined;
|
|
100
|
+
if (!key || key.kty !== 'EC' || key.crv !== 'P-256' || key.alg !== 'ES256') {
|
|
101
|
+
throw new Error(`no ES256 verification key for ${header.kid}`);
|
|
102
|
+
}
|
|
103
|
+
let publicKey;
|
|
104
|
+
try {
|
|
105
|
+
publicKey = createPublicKey({ key: { kty: key.kty, crv: key.crv, x: key.x, y: key.y }, format: 'jwk' });
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(`verification key ${header.kid} is invalid`);
|
|
108
|
+
}
|
|
109
|
+
const valid = verifySignature(
|
|
110
|
+
'sha256',
|
|
111
|
+
Buffer.from(`${envelope.protected}.${envelope.payload}`, 'utf8'),
|
|
112
|
+
{ key: publicKey, dsaEncoding: 'ieee-p1363' },
|
|
113
|
+
fromB64url(envelope.signature),
|
|
114
|
+
);
|
|
115
|
+
if (!valid) throw new Error('policy signature did not verify');
|
|
116
|
+
|
|
117
|
+
let payload;
|
|
118
|
+
try {
|
|
119
|
+
payload = JSON.parse(fromB64url(envelope.payload).toString('utf8'));
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Error('policy payload is invalid');
|
|
122
|
+
}
|
|
123
|
+
if (payload.schema !== POLICY_FORMAT ||
|
|
124
|
+
!Number.isSafeInteger(payload.version) || payload.version < 1 ||
|
|
125
|
+
typeof payload.rules !== 'object' || payload.rules === null) {
|
|
126
|
+
throw new Error('policy payload is invalid');
|
|
127
|
+
}
|
|
128
|
+
const rules = validateRules(payload.rules);
|
|
129
|
+
return {
|
|
130
|
+
version: payload.version,
|
|
131
|
+
rules,
|
|
132
|
+
kid: header.kid,
|
|
133
|
+
digest: `sha256:${createHash('sha256').update(envelope.payload).digest('hex')}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeRules(dir, rules) {
|
|
138
|
+
const file = paths(dir).rules;
|
|
139
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
140
|
+
writeFileSync(temporary, `${JSON.stringify(rules, null, 2)}\n`, { mode: 0o600 });
|
|
141
|
+
renameSync(temporary, file);
|
|
142
|
+
}
|
package/src/setup.mjs
CHANGED
|
@@ -10,10 +10,12 @@ import { join, resolve } from 'node:path';
|
|
|
10
10
|
import { argsHashOf } from './rules.mjs';
|
|
11
11
|
|
|
12
12
|
const AGENT_BLOCK = `<!-- attesso:start -->
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
Bounds evaluates your tool calls locally and records every decision. In observe mode
|
|
14
|
+
a refusal is recorded but not blocked; in enforce mode it blocks. When a call is
|
|
15
|
+
refused you get a tool error naming the rule and the step that tainted the task.
|
|
16
|
+
Do not retry a refused call unchanged: read \`attesso explain <seq>\`, take another
|
|
17
|
+
path, or ask the user whether the rule is wrong. To see rules the ledger supports,
|
|
18
|
+
run \`attesso suggest\`.
|
|
17
19
|
<!-- attesso:end -->`;
|
|
18
20
|
|
|
19
21
|
function stripAgentBlock(text) {
|
package/src/ui/static/index.html
CHANGED
|
@@ -686,7 +686,10 @@
|
|
|
686
686
|
el('agent-sub').textContent = 'Hook active' + seq;
|
|
687
687
|
el('agent-action').textContent = 'Manage agent';
|
|
688
688
|
} else {
|
|
689
|
-
|
|
689
|
+
const wired = Array.isArray(agentData.wired) ? agentData.wired.length : 0;
|
|
690
|
+
el('agent-sub').textContent = wired
|
|
691
|
+
? 'Hook is ' + (agentData.mode || 'not configured') + ' - connect any agent to start observing'
|
|
692
|
+
: 'No harness wired - connect an agent to start observing';
|
|
690
693
|
el('agent-action').textContent = 'Connect agent';
|
|
691
694
|
}
|
|
692
695
|
renderAgentPanel();
|
|
@@ -903,7 +906,11 @@
|
|
|
903
906
|
renderAgentStatus();
|
|
904
907
|
if (body.status?.paths) pathState = body.status.paths;
|
|
905
908
|
renderPaths();
|
|
906
|
-
status.textContent = agentData.connected
|
|
909
|
+
status.textContent = agentData.connected
|
|
910
|
+
? 'Agent connected.'
|
|
911
|
+
: mode === 'off'
|
|
912
|
+
? 'Bounds is off; choose Observe and connect again.'
|
|
913
|
+
: 'No agent harness detected; copy the setup command below.';
|
|
907
914
|
} catch (error) {
|
|
908
915
|
status.textContent = 'Connect failed: ' + error.message;
|
|
909
916
|
}
|
|
@@ -943,6 +950,7 @@
|
|
|
943
950
|
function renderFlow(day, preserveCenter = null) {
|
|
944
951
|
if (v1) return renderV1Flow(day, preserveCenter);
|
|
945
952
|
const summary = summarize(day);
|
|
953
|
+
const empty = summary.calls === 0;
|
|
946
954
|
const width = flowWidth();
|
|
947
955
|
const inner = width - GUTTER;
|
|
948
956
|
el('flow-track').style.width = width + 'px';
|
|
@@ -953,13 +961,15 @@
|
|
|
953
961
|
el('flow-count').textContent = fmt(summary.calls) + ' calls';
|
|
954
962
|
const hours = el('flow-hours');
|
|
955
963
|
hours.replaceChildren();
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
964
|
+
if (!empty) {
|
|
965
|
+
for (let hour = 0; hour <= 24; hour += 1) {
|
|
966
|
+
const line = document.createElement('div');
|
|
967
|
+
line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
|
|
968
|
+
line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
|
|
969
|
+
const labelEvery = zoom >= 2 ? 1 : 3;
|
|
970
|
+
if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
|
|
971
|
+
hours.append(line);
|
|
972
|
+
}
|
|
963
973
|
}
|
|
964
974
|
const lanes = el('flow-lanes');
|
|
965
975
|
lanes.replaceChildren();
|
|
@@ -1008,18 +1018,20 @@
|
|
|
1008
1018
|
for (const row of rows) markerBySeq.set(String(row[0]), marker);
|
|
1009
1019
|
fragments.get(cap).append(marker);
|
|
1010
1020
|
}
|
|
1011
|
-
|
|
1012
|
-
const
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1021
|
+
if (!empty) {
|
|
1022
|
+
for (const cap of CAPS) {
|
|
1023
|
+
const lane = document.createElement('div');
|
|
1024
|
+
lane.className = 'f-lane';
|
|
1025
|
+
lane.dataset.capability = cap;
|
|
1026
|
+
const label = document.createElement('div');
|
|
1027
|
+
label.className = 'f-label';
|
|
1028
|
+
label.textContent = cap.toUpperCase();
|
|
1029
|
+
const track = document.createElement('div');
|
|
1030
|
+
track.className = 'f-track';
|
|
1031
|
+
track.append(fragments.get(cap));
|
|
1032
|
+
lane.append(label, track);
|
|
1033
|
+
lanes.append(lane);
|
|
1034
|
+
}
|
|
1023
1035
|
}
|
|
1024
1036
|
const scroller = el('flow-scroller');
|
|
1025
1037
|
if (preserveCenter == null) {
|
|
@@ -1036,6 +1048,7 @@
|
|
|
1036
1048
|
|
|
1037
1049
|
function renderV1Flow(day, preserveCenter = null) {
|
|
1038
1050
|
const summary = summarize(day);
|
|
1051
|
+
const empty = summary.calls === 0;
|
|
1039
1052
|
const buckets = flowCache.get(day)?.buckets || [];
|
|
1040
1053
|
const width = flowWidth();
|
|
1041
1054
|
const inner = width - GUTTER;
|
|
@@ -1050,13 +1063,15 @@
|
|
|
1050
1063
|
el('flow-count').textContent = fmt(summary.calls) + ' calls';
|
|
1051
1064
|
const hours = el('flow-hours');
|
|
1052
1065
|
hours.replaceChildren();
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1066
|
+
if (!empty) {
|
|
1067
|
+
for (let hour = 0; hour <= 24; hour += 1) {
|
|
1068
|
+
const line = document.createElement('div');
|
|
1069
|
+
line.className = 'flow-hour' + (hour % 3 === 0 ? ' major' : '');
|
|
1070
|
+
line.style.left = (GUTTER + (hour / 24) * inner) + 'px';
|
|
1071
|
+
const labelEvery = zoom >= 2 ? 1 : 3;
|
|
1072
|
+
if (hour % labelEvery === 0) line.textContent = String(hour).padStart(2, '0') + ':00';
|
|
1073
|
+
hours.append(line);
|
|
1074
|
+
}
|
|
1060
1075
|
}
|
|
1061
1076
|
const lanes = el('flow-lanes');
|
|
1062
1077
|
lanes.replaceChildren();
|
|
@@ -1092,18 +1107,20 @@
|
|
|
1092
1107
|
}
|
|
1093
1108
|
fragments.get(cap).append(marker);
|
|
1094
1109
|
}
|
|
1095
|
-
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1110
|
+
if (!empty) {
|
|
1111
|
+
for (const cap of CAPS) {
|
|
1112
|
+
const lane = document.createElement('div');
|
|
1113
|
+
lane.className = 'f-lane';
|
|
1114
|
+
lane.dataset.capability = cap;
|
|
1115
|
+
const label = document.createElement('div');
|
|
1116
|
+
label.className = 'f-label';
|
|
1117
|
+
label.textContent = cap.toUpperCase();
|
|
1118
|
+
const track = document.createElement('div');
|
|
1119
|
+
track.className = 'f-track';
|
|
1120
|
+
track.append(fragments.get(cap));
|
|
1121
|
+
lane.append(label, track);
|
|
1122
|
+
lanes.append(lane);
|
|
1123
|
+
}
|
|
1107
1124
|
}
|
|
1108
1125
|
const scroller = el('flow-scroller');
|
|
1109
1126
|
if (preserveCenter == null) {
|