ai-usage-analyzer 0.4.0 → 0.5.1
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 +38 -19
- package/bin/ai-usage.js +24 -19
- package/package.json +8 -8
- package/src/aggregate.js +24 -2
- package/src/live.js +132 -0
- package/src/loaders.js +90 -27
- package/src/markdown.js +13 -10
- package/src/pricing.js +49 -0
- package/src/quota.js +194 -0
- package/src/render.js +20 -14
- package/src/server.js +68 -0
- package/src/tools.js +5 -49
- package/src/tui.js +24 -8
- package/web/app.js +1059 -0
- package/web/dates.js +35 -0
- package/web/favicon.svg +1 -0
- package/web/fonts/OFL.txt +93 -0
- package/web/fonts/manrope.woff2 +0 -0
- package/web/icons/claude.svg +1 -0
- package/web/icons/openai.svg +1 -0
- package/web/icons/opencode.svg +1 -0
- package/web/index.html +233 -0
- package/web/style.css +369 -0
package/src/render.js
CHANGED
|
@@ -72,9 +72,10 @@ export function fmtCompact(n) {
|
|
|
72
72
|
return String(n);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export function fmtCost(n) {
|
|
75
|
+
export function fmtCost(n, status) {
|
|
76
|
+
if (status === "unavailable") return "n/a";
|
|
76
77
|
if (!n) return "—";
|
|
77
|
-
return "$" + Number(n).toFixed(2);
|
|
78
|
+
return (status === "estimated" || status === "partial" ? "~" : "") + "$" + Number(n).toFixed(2) + (status === "partial" ? "*" : "");
|
|
78
79
|
}
|
|
79
80
|
function fmtDuration(ms) {
|
|
80
81
|
if (!ms || ms <= 0) return chalk.dim("—");
|
|
@@ -175,6 +176,7 @@ export function renderHeader({
|
|
|
175
176
|
totalSessions,
|
|
176
177
|
totalTokens,
|
|
177
178
|
totalCost,
|
|
179
|
+
costStatus,
|
|
178
180
|
dateRange,
|
|
179
181
|
}) {
|
|
180
182
|
const title = gradient.pastel.multiline("◆ AI TOKEN ANALYZER ◆");
|
|
@@ -183,7 +185,7 @@ export function renderHeader({
|
|
|
183
185
|
" • " +
|
|
184
186
|
chalk.bold.cyan(`${fmtCompact(totalTokens)} total tokens`) +
|
|
185
187
|
(totalCost
|
|
186
|
-
? " • " + chalk.bold.yellow(
|
|
188
|
+
? " • " + chalk.bold.yellow(`${fmtCost(totalCost, costStatus)} USD`)
|
|
187
189
|
: "");
|
|
188
190
|
const range =
|
|
189
191
|
dateRange[0] && dateRange[1]
|
|
@@ -338,7 +340,7 @@ export function renderOverview(records, detections) {
|
|
|
338
340
|
fmtInt(p.n),
|
|
339
341
|
fmtCompact(p.tokensTotal),
|
|
340
342
|
fmtCompact(p.avg),
|
|
341
|
-
fmtCost(p.cost),
|
|
343
|
+
fmtCost(p.cost, p.costStatus),
|
|
342
344
|
]);
|
|
343
345
|
}
|
|
344
346
|
pt.push([
|
|
@@ -346,15 +348,18 @@ export function renderOverview(records, detections) {
|
|
|
346
348
|
chalk.bold(fmtInt(records.length)),
|
|
347
349
|
chalk.bold(fmtCompact(t.tokensTotal)),
|
|
348
350
|
chalk.bold(fmtCompact(t.avg)),
|
|
349
|
-
chalk.bold(fmtCost(t.cost)),
|
|
351
|
+
chalk.bold(fmtCost(t.cost, t.costStatus)),
|
|
350
352
|
]);
|
|
351
353
|
|
|
352
354
|
const body =
|
|
353
355
|
pt.toString() +
|
|
356
|
+
"\n\n" + `Average daily tokens: ${fmtCompact(t.avgDailyTokens)}` +
|
|
354
357
|
"\n\n" +
|
|
355
358
|
chalk.bold.underline("Token Breakdown") +
|
|
356
359
|
"\n" +
|
|
357
|
-
bt.toString()
|
|
360
|
+
bt.toString() +
|
|
361
|
+
"\n\n" + chalk.dim("~ estimated API cost; * partial; n/a unavailable") +
|
|
362
|
+
(t.unknownModels.length ? "\n" + chalk.dim(`Unpriced models: ${t.unknownModels.map(m => String(m).replace(/[\x00-\x1f\x7f-\x9f]/g, "")).join(", ")}`) : "");
|
|
358
363
|
return boxen(body, {
|
|
359
364
|
title: chalk.bold("Overview"),
|
|
360
365
|
borderStyle: "round",
|
|
@@ -526,7 +531,7 @@ export function renderProjectSessions(records, projectName) {
|
|
|
526
531
|
colorize(r.tool, toolColor(r.tool)),
|
|
527
532
|
dur,
|
|
528
533
|
fmtCompact(r.tokensTotal),
|
|
529
|
-
r.cost
|
|
534
|
+
fmtCost(r.cost, r.costStatus),
|
|
530
535
|
titleStr,
|
|
531
536
|
]);
|
|
532
537
|
} else {
|
|
@@ -539,7 +544,7 @@ export function renderProjectSessions(records, projectName) {
|
|
|
539
544
|
fmtCompact(r.tokensInput),
|
|
540
545
|
fmtCompact(r.tokensOutput),
|
|
541
546
|
fmtCompact(cache),
|
|
542
|
-
r.cost
|
|
547
|
+
fmtCost(r.cost, r.costStatus),
|
|
543
548
|
titleStr,
|
|
544
549
|
]);
|
|
545
550
|
}
|
|
@@ -600,7 +605,7 @@ export function renderPerTool(records) {
|
|
|
600
605
|
}
|
|
601
606
|
row.push(
|
|
602
607
|
fmtCompact(p.tokensTotal),
|
|
603
|
-
fmtCost(p.cost),
|
|
608
|
+
fmtCost(p.cost, p.costStatus),
|
|
604
609
|
fmtCompact(p.avg),
|
|
605
610
|
bar(p.tokensTotal, max, barW),
|
|
606
611
|
);
|
|
@@ -796,7 +801,7 @@ export function renderTopSessions(records, n = 5) {
|
|
|
796
801
|
chalk.bold(String(i + 1)),
|
|
797
802
|
chalk.bold.yellow(fmtCompact(r.tokensTotal)),
|
|
798
803
|
`${fmtCompact(r.tokensInput)}/${fmtCompact(r.tokensOutput)}`,
|
|
799
|
-
r.cost
|
|
804
|
+
fmtCost(r.cost, r.costStatus),
|
|
800
805
|
truncEnd(r.title, 30) || "—",
|
|
801
806
|
]);
|
|
802
807
|
});
|
|
@@ -830,7 +835,7 @@ export function renderTopSessions(records, n = 5) {
|
|
|
830
835
|
chalk.bold.yellow(fmtCompact(r.tokensTotal)),
|
|
831
836
|
fmtCompact(r.tokensInput),
|
|
832
837
|
fmtCompact(r.tokensOutput),
|
|
833
|
-
r.cost
|
|
838
|
+
fmtCost(r.cost, r.costStatus),
|
|
834
839
|
shortModel(r.model),
|
|
835
840
|
truncEnd(r.project, 30),
|
|
836
841
|
truncEnd(r.title, 40) || "—",
|
|
@@ -850,6 +855,7 @@ export function renderTopSessions(records, n = 5) {
|
|
|
850
855
|
|
|
851
856
|
export function renderNotes(detections, errors) {
|
|
852
857
|
const lines = [];
|
|
858
|
+
lines.push(chalk.dim("Costs: ~ estimated API rates; * partial, excludes unpriced models; n/a unavailable."));
|
|
853
859
|
lines.push(chalk.bold.underline("Token definitions"));
|
|
854
860
|
lines.push(
|
|
855
861
|
` • ${chalk.cyan("Input")} — prompt tokens (what was sent to the model)`,
|
|
@@ -861,7 +867,7 @@ export function renderNotes(detections, errors) {
|
|
|
861
867
|
` • ${chalk.cyan("Cache Read")} — prompt tokens served from provider cache (cheap)`,
|
|
862
868
|
);
|
|
863
869
|
lines.push(
|
|
864
|
-
` • ${chalk.cyan("Cache Write")} — prompt tokens cached for future use
|
|
870
|
+
` • ${chalk.cyan("Cache Write")} — prompt tokens cached for future use`,
|
|
865
871
|
);
|
|
866
872
|
lines.push(
|
|
867
873
|
` • ${chalk.yellow("Reasoning")} — extended thinking / chain-of-thought tokens`,
|
|
@@ -902,9 +908,9 @@ export function renderNotes(detections, errors) {
|
|
|
902
908
|
lines.push("");
|
|
903
909
|
lines.push(chalk.bold.underline("Env overrides"));
|
|
904
910
|
lines.push(
|
|
905
|
-
" $CLAUDE_HOME $CODEX_HOME $OPENCODE_HOME
|
|
911
|
+
" $CLAUDE_HOME $CODEX_HOME $OPENCODE_HOME",
|
|
906
912
|
);
|
|
907
|
-
lines.push(" $
|
|
913
|
+
lines.push(" $ANTIGRAVITY_HOME $GEMINI_HOME");
|
|
908
914
|
lines.push(
|
|
909
915
|
" $AI_USAGE_PATHS_JSON = " + chalk.italic('\'{"codex":"/custom/path"}\''),
|
|
910
916
|
);
|
package/src/server.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { detectAll } from './detectors.js';
|
|
5
|
+
import { loadAll } from './loaders.js';
|
|
6
|
+
|
|
7
|
+
const assets = new Map([
|
|
8
|
+
['/', ['../web/index.html', 'text/html; charset=utf-8']],
|
|
9
|
+
['/app.js', ['../web/app.js', 'text/javascript; charset=utf-8']],
|
|
10
|
+
['/dates.js', ['../web/dates.js', 'text/javascript; charset=utf-8']],
|
|
11
|
+
['/aggregate.js', ['./aggregate.js', 'text/javascript; charset=utf-8']],
|
|
12
|
+
['/style.css', ['../web/style.css', 'text/css; charset=utf-8']],
|
|
13
|
+
['/favicon.svg', ['../web/favicon.svg', 'image/svg+xml']],
|
|
14
|
+
['/gsap.js', [import.meta.resolve('gsap/dist/gsap.min.js'), 'text/javascript; charset=utf-8']],
|
|
15
|
+
['/fonts/manrope.woff2', ['../web/fonts/manrope.woff2', 'font/woff2']],
|
|
16
|
+
['/icons/claude.svg', ['../web/icons/claude.svg', 'image/svg+xml']],
|
|
17
|
+
['/icons/openai.svg', ['../web/icons/openai.svg', 'image/svg+xml']],
|
|
18
|
+
['/icons/opencode.svg', ['../web/icons/opencode.svg', 'image/svg+xml']],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export function createDashboardServer() {
|
|
22
|
+
return createServer(async (req, res) => {
|
|
23
|
+
const host = req.headers.host || '';
|
|
24
|
+
if (!/^127\.0\.0\.1:\d+$/.test(host) || (req.headers.origin && req.headers.origin !== `http://${host}`) || req.headers['sec-fetch-site'] === 'cross-site') {
|
|
25
|
+
res.writeHead(403).end('Forbidden');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
29
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
30
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'");
|
|
31
|
+
if (req.method !== 'GET') {
|
|
32
|
+
res.writeHead(405, { Allow: 'GET' }).end();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const path = new URL(req.url, `http://${host}`).pathname;
|
|
37
|
+
if (path === '/api/usage') {
|
|
38
|
+
const detections = detectAll();
|
|
39
|
+
const { records, errors } = await loadAll(detections);
|
|
40
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ detections, records, errors, generatedAt: new Date().toISOString() }));
|
|
41
|
+
} else if (assets.has(path)) {
|
|
42
|
+
const [file, type] = assets.get(path);
|
|
43
|
+
const content = await readFile(new URL(file, import.meta.url));
|
|
44
|
+
res.writeHead(200, { 'Content-Type': type }).end(content);
|
|
45
|
+
} else {
|
|
46
|
+
res.writeHead(404).end('Not found');
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'Could not load local usage data.' }));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function startDashboard() {
|
|
55
|
+
const server = createDashboardServer();
|
|
56
|
+
await new Promise((resolve, reject) => {
|
|
57
|
+
server.once('error', reject);
|
|
58
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
59
|
+
});
|
|
60
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
61
|
+
console.log(`AI Usage Analyzer is running at ${url}\nPress Ctrl+C to stop.`);
|
|
62
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
63
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
64
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
65
|
+
child.on('error', () => {});
|
|
66
|
+
child.unref();
|
|
67
|
+
return server;
|
|
68
|
+
}
|
package/src/tools.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// data, also wire a loader branch in src/loaders.js.
|
|
9
9
|
|
|
10
10
|
import { statSync, readdirSync, existsSync } from 'node:fs';
|
|
11
|
-
import { join } from 'node:path';
|
|
11
|
+
import { basename, dirname, join } from 'node:path';
|
|
12
12
|
import { homedir, platform } from 'node:os';
|
|
13
13
|
import { env } from 'node:process';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
@@ -112,10 +112,10 @@ export const TOOLS = [
|
|
|
112
112
|
barChar: '▓',
|
|
113
113
|
kind: 'jsonl-rollout',
|
|
114
114
|
envVar: 'CODEX_HOME',
|
|
115
|
-
candidatePaths: () =>
|
|
116
|
-
env.CODEX_HOME
|
|
117
|
-
|
|
118
|
-
count: countJsonlRecursive,
|
|
115
|
+
candidatePaths: () => env.CODEX_HOME
|
|
116
|
+
? [env.CODEX_HOME]
|
|
117
|
+
: [join(HOME, '.codex', 'sessions'), join(HOME, '.codex', 'archived_sessions')],
|
|
118
|
+
count: path => countJsonlRecursive(path) + (basename(path) === 'sessions' ? countJsonlRecursive(join(dirname(path), 'archived_sessions')) : 0),
|
|
119
119
|
hasTokens: true,
|
|
120
120
|
description: '~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl',
|
|
121
121
|
},
|
|
@@ -141,50 +141,6 @@ export const TOOLS = [
|
|
|
141
141
|
description: '~/.local/share/opencode/opencode.db (tokens + cost)',
|
|
142
142
|
},
|
|
143
143
|
|
|
144
|
-
// -----------------------------------------------------------------------
|
|
145
|
-
// MimoCode (same schema as OpenCode)
|
|
146
|
-
// -----------------------------------------------------------------------
|
|
147
|
-
{
|
|
148
|
-
key: 'mimocode',
|
|
149
|
-
name: 'MimoCode',
|
|
150
|
-
label: 'mimocode',
|
|
151
|
-
color: '#f1fa8c', // yellow
|
|
152
|
-
barChar: '░',
|
|
153
|
-
kind: 'sqlite',
|
|
154
|
-
envVar: 'MIMOCODE_HOME',
|
|
155
|
-
candidatePaths: () => [
|
|
156
|
-
env.MIMOCODE_HOME
|
|
157
|
-
? join(env.MIMOCODE_HOME, 'mimocode.db')
|
|
158
|
-
: join(HOME, '.local', 'share', 'mimocode', 'mimocode.db'),
|
|
159
|
-
],
|
|
160
|
-
count: countSqliteRows,
|
|
161
|
-
hasTokens: true,
|
|
162
|
-
description: '~/.local/share/mimocode/mimocode.db (tokens + cost)',
|
|
163
|
-
},
|
|
164
|
-
|
|
165
|
-
// -----------------------------------------------------------------------
|
|
166
|
-
// GitHub Copilot CLI (presence only)
|
|
167
|
-
// -----------------------------------------------------------------------
|
|
168
|
-
{
|
|
169
|
-
key: 'copilot',
|
|
170
|
-
name: 'GitHub Copilot',
|
|
171
|
-
label: 'copilot',
|
|
172
|
-
color: '#3b82f6', // deep blue
|
|
173
|
-
barChar: '·',
|
|
174
|
-
kind: 'jsonl-events',
|
|
175
|
-
envVar: 'COPILOT_HOME',
|
|
176
|
-
candidatePaths: () => {
|
|
177
|
-
const base = env.COPILOT_HOME || join(HOME, '.copilot');
|
|
178
|
-
return [
|
|
179
|
-
join(base, 'session-state'),
|
|
180
|
-
base,
|
|
181
|
-
];
|
|
182
|
-
},
|
|
183
|
-
count: countJsonlRecursive,
|
|
184
|
-
hasTokens: false,
|
|
185
|
-
description: '~/.copilot/session-state/*/events.jsonl (no token data)',
|
|
186
|
-
},
|
|
187
|
-
|
|
188
144
|
// -----------------------------------------------------------------------
|
|
189
145
|
// Antigravity (VS Code variant) - mostly cache; no token data
|
|
190
146
|
// -----------------------------------------------------------------------
|
package/src/tui.js
CHANGED
|
@@ -5,6 +5,7 @@ import chalk from "chalk";
|
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import readline from "node:readline";
|
|
7
7
|
import { perProject } from "./aggregate.js";
|
|
8
|
+
import { renderQuota } from "./quota.js";
|
|
8
9
|
import {
|
|
9
10
|
CURSOR_MARK,
|
|
10
11
|
renderDetections,
|
|
@@ -25,11 +26,14 @@ function visLen(s) {
|
|
|
25
26
|
return s.replace(ANSI_RE, "").length;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export async function runInteractive({ records, detections, errors, topN }) {
|
|
29
|
+
export async function runInteractive({ records, detections, errors, topN, quotaPromise, quotaController, liveTailer }) {
|
|
29
30
|
if (!process.stdout.isTTY || !process.stdin.isTTY) return false;
|
|
30
31
|
|
|
31
32
|
const PROJECTS_VIEW = 1; // index into VIEWS
|
|
32
33
|
|
|
34
|
+
let liveRate = liveTailer ? liveTailer.tick() : null;
|
|
35
|
+
let quotas = null;
|
|
36
|
+
let active = true;
|
|
33
37
|
const VIEWS = [
|
|
34
38
|
{
|
|
35
39
|
key: "1",
|
|
@@ -53,6 +57,7 @@ export async function runInteractive({ records, detections, errors, topN }) {
|
|
|
53
57
|
},
|
|
54
58
|
{ key: "8", label: "Detections", fn: () => renderDetections(detections) },
|
|
55
59
|
{ key: "9", label: "Notes", fn: () => renderNotes(detections, errors) },
|
|
60
|
+
{ key: "0", label: "Quota", fn: () => renderQuota(quotas) },
|
|
56
61
|
];
|
|
57
62
|
|
|
58
63
|
// Project drill-down state (only active when current === PROJECTS_VIEW).
|
|
@@ -128,6 +133,7 @@ export async function runInteractive({ records, detections, errors, topN }) {
|
|
|
128
133
|
" ←/→ h/l switch ↑/↓ j/k scroll PgUp/PgDn g/G top/bot q quit",
|
|
129
134
|
);
|
|
130
135
|
}
|
|
136
|
+
if (liveRate !== null) hint = chalk.dim(` ${Math.round(liveRate)} tokens/min (10m) `) + hint;
|
|
131
137
|
const gap = Math.max(1, w - visLen(hint) - visLen(pct));
|
|
132
138
|
return chalk.bgBlack(hint + " ".repeat(gap) + pct);
|
|
133
139
|
}
|
|
@@ -195,20 +201,29 @@ export async function runInteractive({ records, detections, errors, topN }) {
|
|
|
195
201
|
process.stdout.write("\x1b[?1049h\x1b[?25l");
|
|
196
202
|
draw(true);
|
|
197
203
|
|
|
198
|
-
|
|
199
|
-
|
|
204
|
+
const liveTimer = liveTailer ? setInterval(() => { liveRate = liveTailer.tick(); if (active) draw(false); }, 5000) : null;
|
|
205
|
+
const onResize = () => draw(true);
|
|
206
|
+
process.stdout.on("resize", onResize);
|
|
207
|
+
if (quotaPromise) quotaPromise.then((result) => {
|
|
208
|
+
quotas = result;
|
|
209
|
+
if (active && current === 9) { loadView(); draw(false); }
|
|
200
210
|
});
|
|
201
211
|
|
|
202
212
|
return new Promise((resolve) => {
|
|
203
213
|
function quit() {
|
|
204
214
|
// Leave alternate screen — original terminal content is restored automatically.
|
|
215
|
+
active = false;
|
|
216
|
+
if (liveTimer) clearInterval(liveTimer);
|
|
217
|
+
quotaController?.abort();
|
|
218
|
+
process.stdout.removeListener("resize", onResize);
|
|
219
|
+
process.stdin.removeListener("keypress", onKeypress);
|
|
205
220
|
process.stdout.write("\x1b[?25h\x1b[?1049l");
|
|
206
221
|
process.stdin.setRawMode(false);
|
|
207
222
|
process.stdin.pause();
|
|
208
|
-
resolve();
|
|
223
|
+
resolve(true);
|
|
209
224
|
}
|
|
210
225
|
|
|
211
|
-
|
|
226
|
+
function onKeypress(_str, key) {
|
|
212
227
|
if (!key) return;
|
|
213
228
|
const usable = usableRows();
|
|
214
229
|
const maxScroll = Math.max(0, lines.length - usable);
|
|
@@ -270,8 +285,8 @@ export async function runInteractive({ records, detections, errors, topN }) {
|
|
|
270
285
|
draw(false);
|
|
271
286
|
return;
|
|
272
287
|
}
|
|
273
|
-
if (_str && /^[
|
|
274
|
-
const idx = parseInt(_str, 10) - 1;
|
|
288
|
+
if (_str && /^[0-9]$/.test(_str)) {
|
|
289
|
+
const idx = _str === "0" ? 9 : parseInt(_str, 10) - 1;
|
|
275
290
|
if (idx < VIEWS.length) {
|
|
276
291
|
current = idx;
|
|
277
292
|
if (current === PROJECTS_VIEW) proj.mode = "list";
|
|
@@ -305,6 +320,7 @@ export async function runInteractive({ records, detections, errors, topN }) {
|
|
|
305
320
|
}
|
|
306
321
|
|
|
307
322
|
if (moved) draw(false);
|
|
308
|
-
}
|
|
323
|
+
}
|
|
324
|
+
process.stdin.on("keypress", onKeypress);
|
|
309
325
|
});
|
|
310
326
|
}
|