@matthesketh/fleet 1.1.0 → 1.6.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 +183 -251
- package/dist/adapters/detector/index.d.ts +8 -0
- package/dist/adapters/detector/index.js +54 -0
- package/dist/adapters/notifier/index.d.ts +2 -0
- package/dist/adapters/notifier/index.js +2 -0
- package/dist/adapters/notifier/stdout.d.ts +2 -0
- package/dist/adapters/notifier/stdout.js +8 -0
- package/dist/adapters/notifier/webhook.d.ts +9 -0
- package/dist/adapters/notifier/webhook.js +38 -0
- package/dist/adapters/runner/claude-cli.d.ts +7 -0
- package/dist/adapters/runner/claude-cli.js +231 -0
- package/dist/adapters/runner/mcp-call.d.ts +8 -0
- package/dist/adapters/runner/mcp-call.js +82 -0
- package/dist/adapters/runner/shell.d.ts +2 -0
- package/dist/adapters/runner/shell.js +103 -0
- package/dist/adapters/scheduler/systemd-timer.d.ts +17 -0
- package/dist/adapters/scheduler/systemd-timer.js +149 -0
- package/dist/adapters/signals/ci-status.d.ts +2 -0
- package/dist/adapters/signals/ci-status.js +79 -0
- package/dist/adapters/signals/container-up.d.ts +5 -0
- package/dist/adapters/signals/container-up.js +54 -0
- package/dist/adapters/signals/git-clean.d.ts +2 -0
- package/dist/adapters/signals/git-clean.js +55 -0
- package/dist/adapters/signals/index.d.ts +6 -0
- package/dist/adapters/signals/index.js +7 -0
- package/dist/adapters/types.d.ts +52 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.js +43 -2
- package/dist/commands/add.js +0 -6
- package/dist/commands/boot-start.d.ts +1 -0
- package/dist/commands/boot-start.js +51 -0
- package/dist/commands/deploy.js +13 -0
- package/dist/commands/deps.js +5 -0
- package/dist/commands/egress.d.ts +1 -0
- package/dist/commands/egress.js +106 -0
- package/dist/commands/freeze.d.ts +4 -0
- package/dist/commands/freeze.js +64 -0
- package/dist/commands/logs.d.ts +1 -1
- package/dist/commands/logs.js +237 -8
- package/dist/commands/patch-systemd.d.ts +1 -0
- package/dist/commands/patch-systemd.js +126 -0
- package/dist/commands/rollback.d.ts +1 -0
- package/dist/commands/rollback.js +58 -0
- package/dist/commands/routine-run.d.ts +1 -0
- package/dist/commands/routine-run.js +122 -0
- package/dist/commands/routines.d.ts +1 -0
- package/dist/commands/routines.js +25 -0
- package/dist/commands/secrets.js +449 -16
- package/dist/commands/status.js +7 -3
- package/dist/commands/watchdog.d.ts +1 -1
- package/dist/commands/watchdog.js +16 -40
- package/dist/core/boot-refresh.d.ts +57 -0
- package/dist/core/boot-refresh.js +116 -0
- package/dist/core/deps/actors/pr-creator.js +11 -9
- package/dist/core/deps/collectors/docker-running.js +2 -2
- package/dist/core/deps/collectors/github-pr.js +5 -2
- package/dist/core/deps/collectors/npm.js +10 -5
- package/dist/core/deps/collectors/vulnerability.js +10 -6
- package/dist/core/deps/reporters/motd.js +1 -1
- package/dist/core/deps/reporters/telegram.js +2 -29
- package/dist/core/docker.js +45 -15
- package/dist/core/egress.d.ts +41 -0
- package/dist/core/egress.js +161 -0
- package/dist/core/exec.d.ts +7 -1
- package/dist/core/exec.js +25 -17
- package/dist/core/git.d.ts +1 -0
- package/dist/core/git.js +36 -23
- package/dist/core/github.js +27 -8
- package/dist/core/health.d.ts +3 -0
- package/dist/core/health.js +15 -3
- package/dist/core/logs-multi.d.ts +73 -0
- package/dist/core/logs-multi.js +163 -0
- package/dist/core/logs-policy.d.ts +55 -0
- package/dist/core/logs-policy.js +148 -0
- package/dist/core/nginx.js +8 -4
- package/dist/core/notify.d.ts +15 -0
- package/dist/core/notify.js +55 -0
- package/dist/core/registry.d.ts +25 -0
- package/dist/core/registry.js +57 -10
- package/dist/core/routines/cost-queries.d.ts +24 -0
- package/dist/core/routines/cost-queries.js +65 -0
- package/dist/core/routines/db.d.ts +9 -0
- package/dist/core/routines/db.js +126 -0
- package/dist/core/routines/defaults.d.ts +2 -0
- package/dist/core/routines/defaults.js +72 -0
- package/dist/core/routines/engine.d.ts +59 -0
- package/dist/core/routines/engine.js +175 -0
- package/dist/core/routines/incidents.d.ts +13 -0
- package/dist/core/routines/incidents.js +35 -0
- package/dist/core/routines/schema.d.ts +418 -0
- package/dist/core/routines/schema.js +113 -0
- package/dist/core/routines/signals-collector.d.ts +35 -0
- package/dist/core/routines/signals-collector.js +114 -0
- package/dist/core/routines/store.d.ts +316 -0
- package/dist/core/routines/store.js +99 -0
- package/dist/core/routines/test-utils.d.ts +2 -0
- package/dist/core/routines/test-utils.js +13 -0
- package/dist/core/secrets-audit.d.ts +21 -0
- package/dist/core/secrets-audit.js +60 -0
- package/dist/core/secrets-metadata.d.ts +39 -0
- package/dist/core/secrets-metadata.js +82 -0
- package/dist/core/secrets-motd.d.ts +20 -0
- package/dist/core/secrets-motd.js +72 -0
- package/dist/core/secrets-ops.d.ts +3 -1
- package/dist/core/secrets-ops.js +78 -13
- package/dist/core/secrets-providers.d.ts +50 -0
- package/dist/core/secrets-providers.js +291 -0
- package/dist/core/secrets-rotation.d.ts +52 -0
- package/dist/core/secrets-rotation.js +165 -0
- package/dist/core/secrets-snapshots.d.ts +26 -0
- package/dist/core/secrets-snapshots.js +95 -0
- package/dist/core/secrets-validate.js +2 -1
- package/dist/core/secrets.d.ts +12 -1
- package/dist/core/secrets.js +35 -24
- package/dist/core/self-update.d.ts +41 -0
- package/dist/core/self-update.js +73 -0
- package/dist/core/systemd.js +29 -12
- package/dist/core/telegram.d.ts +6 -0
- package/dist/core/telegram.js +32 -0
- package/dist/core/validate.d.ts +7 -0
- package/dist/core/validate.js +42 -0
- package/dist/index.js +0 -4
- package/dist/mcp/deps-tools.js +9 -1
- package/dist/mcp/git-tools.js +4 -4
- package/dist/mcp/server.js +193 -8
- package/dist/templates/systemd.js +3 -3
- package/dist/templates/unseal.js +5 -1
- package/dist/tui/components/Confirm.js +3 -4
- package/dist/tui/components/Header.js +37 -8
- package/dist/tui/components/KeyHint.js +14 -5
- package/dist/tui/exec-bridge.js +26 -12
- package/dist/tui/hooks/use-fleet-data.js +5 -2
- package/dist/tui/hooks/use-health.js +5 -2
- package/dist/tui/hooks/use-terminal-size.d.ts +1 -0
- package/dist/tui/hooks/use-terminal-size.js +1 -0
- package/dist/tui/router.js +133 -8
- package/dist/tui/routines/RoutinesApp.d.ts +8 -0
- package/dist/tui/routines/RoutinesApp.js +277 -0
- package/dist/tui/routines/components/AlertsPanel.d.ts +7 -0
- package/dist/tui/routines/components/AlertsPanel.js +22 -0
- package/dist/tui/routines/components/AlertsPanel.test.d.ts +1 -0
- package/dist/tui/routines/components/AlertsPanel.test.js +52 -0
- package/dist/tui/routines/components/CommandPalette.d.ts +12 -0
- package/dist/tui/routines/components/CommandPalette.js +21 -0
- package/dist/tui/routines/components/LiveRunPanel.d.ts +12 -0
- package/dist/tui/routines/components/LiveRunPanel.js +107 -0
- package/dist/tui/routines/components/RoutineForm.d.ts +8 -0
- package/dist/tui/routines/components/RoutineForm.js +254 -0
- package/dist/tui/routines/components/SignalsGrid.d.ts +13 -0
- package/dist/tui/routines/components/SignalsGrid.js +34 -0
- package/dist/tui/routines/components/SignalsGrid.test.d.ts +1 -0
- package/dist/tui/routines/components/SignalsGrid.test.js +43 -0
- package/dist/tui/routines/format.d.ts +7 -0
- package/dist/tui/routines/format.js +51 -0
- package/dist/tui/routines/hooks/use-git-fleet.d.ts +33 -0
- package/dist/tui/routines/hooks/use-git-fleet.js +82 -0
- package/dist/tui/routines/hooks/use-logs-stream.d.ts +13 -0
- package/dist/tui/routines/hooks/use-logs-stream.js +64 -0
- package/dist/tui/routines/hooks/use-ops-fleet.d.ts +20 -0
- package/dist/tui/routines/hooks/use-ops-fleet.js +70 -0
- package/dist/tui/routines/hooks/use-repo-detail.d.ts +31 -0
- package/dist/tui/routines/hooks/use-repo-detail.js +104 -0
- package/dist/tui/routines/hooks/use-security.d.ts +33 -0
- package/dist/tui/routines/hooks/use-security.js +110 -0
- package/dist/tui/routines/hooks/use-signals.d.ts +9 -0
- package/dist/tui/routines/hooks/use-signals.js +60 -0
- package/dist/tui/routines/runtime.d.ts +20 -0
- package/dist/tui/routines/runtime.js +40 -0
- package/dist/tui/routines/tabs/CostTab.d.ts +7 -0
- package/dist/tui/routines/tabs/CostTab.js +24 -0
- package/dist/tui/routines/tabs/DashboardTab.d.ts +15 -0
- package/dist/tui/routines/tabs/DashboardTab.js +10 -0
- package/dist/tui/routines/tabs/GitTab.d.ts +6 -0
- package/dist/tui/routines/tabs/GitTab.js +39 -0
- package/dist/tui/routines/tabs/LogsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/LogsTab.js +58 -0
- package/dist/tui/routines/tabs/OpsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/OpsTab.js +34 -0
- package/dist/tui/routines/tabs/RepoDetailView.d.ts +6 -0
- package/dist/tui/routines/tabs/RepoDetailView.js +12 -0
- package/dist/tui/routines/tabs/RoutinesTab.d.ts +10 -0
- package/dist/tui/routines/tabs/RoutinesTab.js +58 -0
- package/dist/tui/routines/tabs/ScaffoldTab.d.ts +2 -0
- package/dist/tui/routines/tabs/ScaffoldTab.js +127 -0
- package/dist/tui/routines/tabs/SecurityTab.d.ts +6 -0
- package/dist/tui/routines/tabs/SecurityTab.js +31 -0
- package/dist/tui/routines/tabs/SettingsTab.d.ts +6 -0
- package/dist/tui/routines/tabs/SettingsTab.js +61 -0
- package/dist/tui/routines/tabs/TimelineTab.d.ts +7 -0
- package/dist/tui/routines/tabs/TimelineTab.js +26 -0
- package/dist/tui/state.js +16 -1
- package/dist/tui/tests/flicker.test.d.ts +1 -0
- package/dist/tui/tests/flicker.test.js +105 -0
- package/dist/tui/tests/keyboard-integration.test.d.ts +1 -0
- package/dist/tui/tests/keyboard-integration.test.js +120 -0
- package/dist/tui/tests/test-app.d.ts +4 -0
- package/dist/tui/tests/test-app.js +79 -0
- package/dist/tui/types.d.ts +14 -1
- package/dist/tui/views/AppDetail.js +40 -26
- package/dist/tui/views/Dashboard.js +34 -9
- package/dist/tui/views/HealthView.js +42 -12
- package/dist/tui/views/LogsView.js +38 -10
- package/dist/tui/views/MultiLogsView.d.ts +2 -0
- package/dist/tui/views/MultiLogsView.js +165 -0
- package/dist/tui/views/SecretEdit.js +18 -7
- package/dist/tui/views/SecretsView.js +55 -39
- package/dist/ui/prompt.d.ts +52 -0
- package/dist/ui/prompt.js +169 -0
- package/package.json +33 -5
- package/dist/commands/motd.d.ts +0 -1
- package/dist/commands/motd.js +0 -10
- package/dist/templates/motd.d.ts +0 -1
- package/dist/templates/motd.js +0 -7
- package/dist/tui/components/AppList.d.ts +0 -12
- package/dist/tui/components/AppList.js +0 -32
- package/dist/tui/hooks/use-keyboard.d.ts +0 -1
- package/dist/tui/hooks/use-keyboard.js +0 -44
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-source log tailer. Spawns `docker logs -f` per selected container,
|
|
3
|
+
* splits the streams on newlines, applies filters (level / grep / since),
|
|
4
|
+
* emits structured events to a callback. Caller is responsible for rendering.
|
|
5
|
+
*
|
|
6
|
+
* Design notes:
|
|
7
|
+
* - Each container gets its own subprocess so a stuck/dead container can't
|
|
8
|
+
* block the others.
|
|
9
|
+
* - Stdout + stderr are merged. Docker writes app output to stdout for
|
|
10
|
+
* json-file driver containers; some apps log errors to stderr.
|
|
11
|
+
* - Lines are emitted in arrival order per source. Cross-source ordering
|
|
12
|
+
* is best-effort (no global timestamp synchronisation — we don't reorder).
|
|
13
|
+
* - stop() kills the entire process group cleanly. Idempotent.
|
|
14
|
+
* - Used by both the CLI (`fleet logs --all -f`) and the TUI logs view.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
const LEVEL_RANK = {
|
|
18
|
+
debug: 0, info: 1, warn: 2, error: 3,
|
|
19
|
+
};
|
|
20
|
+
const LEVEL_PATTERNS = [
|
|
21
|
+
{ level: 'error', re: /\b(error|err|fatal|critical|exception|panic|trace)\b|\bE\d{4}\b/i },
|
|
22
|
+
{ level: 'warn', re: /\b(warn|warning|deprecated)\b/i },
|
|
23
|
+
{ level: 'debug', re: /\b(debug|trace|verbose)\b/i },
|
|
24
|
+
{ level: 'info', re: /\b(info|notice)\b/i },
|
|
25
|
+
];
|
|
26
|
+
/** Best-effort level inference from a line. Returns 'unknown' if nothing matches. */
|
|
27
|
+
export function inferLevel(text) {
|
|
28
|
+
// First match wins; ordering above puts error before warn etc.
|
|
29
|
+
for (const { level, re } of LEVEL_PATTERNS)
|
|
30
|
+
if (re.test(text))
|
|
31
|
+
return level;
|
|
32
|
+
return 'unknown';
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Glob-match a container name against a pattern. Supports * wildcards.
|
|
36
|
+
* `*-postgres` matches `glitchtip-postgres`, `shared-postgres`, etc.
|
|
37
|
+
*/
|
|
38
|
+
export function matchesContainerGlob(name, glob) {
|
|
39
|
+
const re = new RegExp('^' + glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
|
|
40
|
+
return re.test(name);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a selection spec into a flat list of LogSource entries.
|
|
44
|
+
* - Empty selection → all containers across all apps
|
|
45
|
+
* - apps + containers can be combined; intersection wins
|
|
46
|
+
*/
|
|
47
|
+
export function resolveSources(apps, selection = {}) {
|
|
48
|
+
const allowedAppNames = selection.apps && selection.apps.length > 0
|
|
49
|
+
? new Set(selection.apps)
|
|
50
|
+
: null;
|
|
51
|
+
const containerGlobs = selection.containers && selection.containers.length > 0
|
|
52
|
+
? selection.containers
|
|
53
|
+
: null;
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const app of apps) {
|
|
56
|
+
if (allowedAppNames && !allowedAppNames.has(app.name))
|
|
57
|
+
continue;
|
|
58
|
+
for (const container of app.containers) {
|
|
59
|
+
if (containerGlobs && !containerGlobs.some(g => matchesContainerGlob(container, g))) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
out.push({ app: app.name, container });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Start tailing the given sources. Calls onLine for every emitted line that
|
|
69
|
+
* passes the filter chain. Returns a handle for graceful shutdown.
|
|
70
|
+
*
|
|
71
|
+
* For test injection, pass a custom `spawnFn` that mimics Node's spawn.
|
|
72
|
+
*/
|
|
73
|
+
export function startMultiTail(sources, opts, onLine, onClose, spawnFn = spawn) {
|
|
74
|
+
const procs = new Map();
|
|
75
|
+
const minLevelRank = opts.level ? LEVEL_RANK[opts.level] : -1;
|
|
76
|
+
const tail = opts.tail ?? 50;
|
|
77
|
+
const follow = opts.follow !== false;
|
|
78
|
+
for (const src of sources) {
|
|
79
|
+
const args = ['logs', '--tail', String(tail)];
|
|
80
|
+
if (follow)
|
|
81
|
+
args.push('-f');
|
|
82
|
+
if (opts.since)
|
|
83
|
+
args.push('--since', opts.since);
|
|
84
|
+
args.push(src.container);
|
|
85
|
+
const proc = spawnFn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
86
|
+
procs.set(src.container, proc);
|
|
87
|
+
let stdoutBuf = '';
|
|
88
|
+
let stderrBuf = '';
|
|
89
|
+
const flushLine = (text) => {
|
|
90
|
+
if (!text)
|
|
91
|
+
return;
|
|
92
|
+
const level = inferLevel(text);
|
|
93
|
+
if (minLevelRank >= 0 && level !== 'unknown') {
|
|
94
|
+
const rank = LEVEL_RANK[level];
|
|
95
|
+
if (rank < minLevelRank)
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (opts.grep && !text.includes(opts.grep))
|
|
99
|
+
return;
|
|
100
|
+
onLine({ ts: new Date(), app: src.app, container: src.container, level, text });
|
|
101
|
+
};
|
|
102
|
+
const handleChunk = (which) => (chunk) => {
|
|
103
|
+
const buf = which === 'out' ? stdoutBuf : stderrBuf;
|
|
104
|
+
const merged = buf + chunk.toString('utf8');
|
|
105
|
+
const parts = merged.split('\n');
|
|
106
|
+
const remainder = parts.pop() ?? '';
|
|
107
|
+
for (const p of parts)
|
|
108
|
+
flushLine(p);
|
|
109
|
+
if (which === 'out')
|
|
110
|
+
stdoutBuf = remainder;
|
|
111
|
+
else
|
|
112
|
+
stderrBuf = remainder;
|
|
113
|
+
};
|
|
114
|
+
proc.stdout?.on('data', handleChunk('out'));
|
|
115
|
+
proc.stderr?.on('data', handleChunk('err'));
|
|
116
|
+
proc.on('close', code => {
|
|
117
|
+
// Flush any unfinished partial line — common when a container dies between newlines.
|
|
118
|
+
if (stdoutBuf) {
|
|
119
|
+
flushLine(stdoutBuf);
|
|
120
|
+
stdoutBuf = '';
|
|
121
|
+
}
|
|
122
|
+
if (stderrBuf) {
|
|
123
|
+
flushLine(stderrBuf);
|
|
124
|
+
stderrBuf = '';
|
|
125
|
+
}
|
|
126
|
+
procs.delete(src.container);
|
|
127
|
+
onClose?.(src, code);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
let stopped = false;
|
|
131
|
+
return {
|
|
132
|
+
active: () => procs.size,
|
|
133
|
+
stop: async () => {
|
|
134
|
+
if (stopped)
|
|
135
|
+
return;
|
|
136
|
+
stopped = true;
|
|
137
|
+
for (const proc of procs.values()) {
|
|
138
|
+
try {
|
|
139
|
+
proc.kill('SIGTERM');
|
|
140
|
+
}
|
|
141
|
+
catch { /* already dead */ }
|
|
142
|
+
}
|
|
143
|
+
// Give them a beat to die gracefully, then escalate.
|
|
144
|
+
await new Promise(resolve => {
|
|
145
|
+
const deadline = Date.now() + 2000;
|
|
146
|
+
const tick = () => {
|
|
147
|
+
if (procs.size === 0 || Date.now() > deadline) {
|
|
148
|
+
for (const proc of procs.values()) {
|
|
149
|
+
try {
|
|
150
|
+
proc.kill('SIGKILL');
|
|
151
|
+
}
|
|
152
|
+
catch { /* already dead */ }
|
|
153
|
+
}
|
|
154
|
+
resolve();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
setTimeout(tick, 50);
|
|
158
|
+
};
|
|
159
|
+
tick();
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log policy: per-app retention/size/level configuration. Materialises as a
|
|
3
|
+
* compose override file (.fleet/logging.override.yml) per app, plus journald
|
|
4
|
+
* vacuum policy. Conservative defaults applied when unset.
|
|
5
|
+
*/
|
|
6
|
+
import type { AppEntry } from './registry.js';
|
|
7
|
+
export interface LogPolicy {
|
|
8
|
+
retentionDays: number;
|
|
9
|
+
maxSizeMB: number;
|
|
10
|
+
level: 'debug' | 'info' | 'warn' | 'error';
|
|
11
|
+
}
|
|
12
|
+
export declare const DEFAULT_POLICY: LogPolicy;
|
|
13
|
+
export declare function effectivePolicy(app: AppEntry): LogPolicy;
|
|
14
|
+
/**
|
|
15
|
+
* Build a docker-compose override that sets json-file driver with rotation.
|
|
16
|
+
* Maps each container to logging.driver/options. Idempotent — overwrites the
|
|
17
|
+
* single override file completely.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildComposeOverride(app: AppEntry, policy: LogPolicy): string;
|
|
20
|
+
export declare function overridePath(app: AppEntry): string;
|
|
21
|
+
export declare function writeComposeOverride(app: AppEntry, policy: LogPolicy): string;
|
|
22
|
+
export interface LogStatus {
|
|
23
|
+
app: string;
|
|
24
|
+
container: string;
|
|
25
|
+
driver: string;
|
|
26
|
+
totalBytes: number | null;
|
|
27
|
+
oldestEntry: string | null;
|
|
28
|
+
policyApplied: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Best-effort docker-side log status. Uses `docker inspect` for driver + size. */
|
|
31
|
+
export declare function getLogStatus(app: AppEntry): LogStatus[];
|
|
32
|
+
/**
|
|
33
|
+
* Vacuum: for json-file driver with rotation already configured, docker
|
|
34
|
+
* handles size automatically. We expose a manual prune that:
|
|
35
|
+
* 1. Truncates the active log file (leaves rotated .1, .2 alone — those age out naturally)
|
|
36
|
+
* 2. For journald-driven containers, runs `journalctl --vacuum-time=Xd` scoped to the unit
|
|
37
|
+
*
|
|
38
|
+
* Returns bytes freed (approximate).
|
|
39
|
+
*/
|
|
40
|
+
export declare function pruneLogs(app: AppEntry, policy: LogPolicy): number;
|
|
41
|
+
/**
|
|
42
|
+
* Read a slice of container logs with bounded options. Used by enhanced
|
|
43
|
+
* `fleet logs` and the MCP tool.
|
|
44
|
+
*/
|
|
45
|
+
export interface LogReadOpts {
|
|
46
|
+
lines?: number;
|
|
47
|
+
level?: 'debug' | 'info' | 'warn' | 'error';
|
|
48
|
+
sinceMinutes?: number;
|
|
49
|
+
grep?: string;
|
|
50
|
+
maxBytes?: number;
|
|
51
|
+
}
|
|
52
|
+
export declare function readContainerLogs(container: string, opts?: LogReadOpts): {
|
|
53
|
+
text: string;
|
|
54
|
+
truncated: boolean;
|
|
55
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log policy: per-app retention/size/level configuration. Materialises as a
|
|
3
|
+
* compose override file (.fleet/logging.override.yml) per app, plus journald
|
|
4
|
+
* vacuum policy. Conservative defaults applied when unset.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { execSafe } from './exec.js';
|
|
9
|
+
export const DEFAULT_POLICY = {
|
|
10
|
+
retentionDays: 7,
|
|
11
|
+
maxSizeMB: 100,
|
|
12
|
+
level: 'info',
|
|
13
|
+
};
|
|
14
|
+
export function effectivePolicy(app) {
|
|
15
|
+
return {
|
|
16
|
+
retentionDays: app.logging?.retentionDays ?? DEFAULT_POLICY.retentionDays,
|
|
17
|
+
maxSizeMB: app.logging?.maxSizeMB ?? DEFAULT_POLICY.maxSizeMB,
|
|
18
|
+
level: app.logging?.level ?? DEFAULT_POLICY.level,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build a docker-compose override that sets json-file driver with rotation.
|
|
23
|
+
* Maps each container to logging.driver/options. Idempotent — overwrites the
|
|
24
|
+
* single override file completely.
|
|
25
|
+
*/
|
|
26
|
+
export function buildComposeOverride(app, policy) {
|
|
27
|
+
const maxSize = `${policy.maxSizeMB}m`;
|
|
28
|
+
const maxFile = '3';
|
|
29
|
+
const services = {};
|
|
30
|
+
for (const ct of app.containers) {
|
|
31
|
+
services[ct] = {
|
|
32
|
+
logging: {
|
|
33
|
+
driver: 'json-file',
|
|
34
|
+
options: { 'max-size': maxSize, 'max-file': maxFile },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Tiny YAML emitter — avoids pulling in a yaml dep for two levels of nesting.
|
|
39
|
+
const lines = ['# Auto-generated by `fleet logs setup`. Do not edit.', 'services:'];
|
|
40
|
+
for (const [name, cfg] of Object.entries(services)) {
|
|
41
|
+
lines.push(` ${name}:`);
|
|
42
|
+
lines.push(` logging:`);
|
|
43
|
+
lines.push(` driver: json-file`);
|
|
44
|
+
lines.push(` options:`);
|
|
45
|
+
lines.push(` max-size: "${cfg.logging.options['max-size']}"`);
|
|
46
|
+
lines.push(` max-file: "${cfg.logging.options['max-file']}"`);
|
|
47
|
+
}
|
|
48
|
+
return lines.join('\n') + '\n';
|
|
49
|
+
}
|
|
50
|
+
export function overridePath(app) {
|
|
51
|
+
return join(app.composePath, '.fleet', 'logging.override.yml');
|
|
52
|
+
}
|
|
53
|
+
export function writeComposeOverride(app, policy) {
|
|
54
|
+
const path = overridePath(app);
|
|
55
|
+
const dir = dirname(path);
|
|
56
|
+
if (!existsSync(dir))
|
|
57
|
+
mkdirSync(dir, { recursive: true });
|
|
58
|
+
writeFileSync(path, buildComposeOverride(app, policy));
|
|
59
|
+
return path;
|
|
60
|
+
}
|
|
61
|
+
/** Best-effort docker-side log status. Uses `docker inspect` for driver + size. */
|
|
62
|
+
export function getLogStatus(app) {
|
|
63
|
+
const override = overridePath(app);
|
|
64
|
+
const policyApplied = existsSync(override);
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const ct of app.containers) {
|
|
67
|
+
const inspect = execSafe('docker', ['inspect', '--format={{.HostConfig.LogConfig.Type}}|{{.LogPath}}', ct]);
|
|
68
|
+
if (!inspect.ok) {
|
|
69
|
+
out.push({ app: app.name, container: ct, driver: 'unknown', totalBytes: null, oldestEntry: null, policyApplied });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const [driver, logPath] = inspect.stdout.trim().split('|');
|
|
73
|
+
let totalBytes = null;
|
|
74
|
+
if (logPath && existsSync(logPath)) {
|
|
75
|
+
try {
|
|
76
|
+
totalBytes = statSync(logPath).size;
|
|
77
|
+
}
|
|
78
|
+
catch { /* ignore */ }
|
|
79
|
+
}
|
|
80
|
+
out.push({ app: app.name, container: ct, driver, totalBytes, oldestEntry: null, policyApplied });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Vacuum: for json-file driver with rotation already configured, docker
|
|
86
|
+
* handles size automatically. We expose a manual prune that:
|
|
87
|
+
* 1. Truncates the active log file (leaves rotated .1, .2 alone — those age out naturally)
|
|
88
|
+
* 2. For journald-driven containers, runs `journalctl --vacuum-time=Xd` scoped to the unit
|
|
89
|
+
*
|
|
90
|
+
* Returns bytes freed (approximate).
|
|
91
|
+
*/
|
|
92
|
+
export function pruneLogs(app, policy) {
|
|
93
|
+
let freed = 0;
|
|
94
|
+
const status = getLogStatus(app);
|
|
95
|
+
for (const s of status) {
|
|
96
|
+
if (s.driver === 'json-file' && s.totalBytes && s.totalBytes > 0) {
|
|
97
|
+
// Use docker to "logs --truncate" — actually no such cmd. Use docker stop + log file truncate? Risky.
|
|
98
|
+
// Safest: skip active truncation; let docker rotation handle it. Just report.
|
|
99
|
+
// We DO truncate via writing 0-length only if the file is over 5x the policy max
|
|
100
|
+
// (which means rotation isn't catching up — clear it manually).
|
|
101
|
+
const maxBytes = policy.maxSizeMB * 1024 * 1024;
|
|
102
|
+
if (s.totalBytes > maxBytes * 5) {
|
|
103
|
+
try {
|
|
104
|
+
const inspect = execSafe('docker', ['inspect', '--format={{.LogPath}}', s.container]);
|
|
105
|
+
const path = inspect.stdout.trim();
|
|
106
|
+
if (path) {
|
|
107
|
+
const beforeSize = s.totalBytes;
|
|
108
|
+
writeFileSync(path, '');
|
|
109
|
+
freed += beforeSize;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch { /* ignore */ }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Journald scope: vacuum by time, scoped to relevant units (best-effort).
|
|
117
|
+
execSafe('journalctl', ['--vacuum-time', `${policy.retentionDays}d`]);
|
|
118
|
+
return freed;
|
|
119
|
+
}
|
|
120
|
+
const LEVEL_PATTERNS = {
|
|
121
|
+
debug: /\b(debug|trace|verbose)\b/i,
|
|
122
|
+
info: /\b(info|warn|warning|error|err|fatal|critical)\b/i,
|
|
123
|
+
warn: /\b(warn|warning|error|err|fatal|critical)\b/i,
|
|
124
|
+
error: /\b(error|err|fatal|critical|exception|panic)\b/i,
|
|
125
|
+
};
|
|
126
|
+
export function readContainerLogs(container, opts = {}) {
|
|
127
|
+
const args = ['logs', '--tail', String(opts.lines ?? 100)];
|
|
128
|
+
if (opts.sinceMinutes)
|
|
129
|
+
args.push('--since', `${opts.sinceMinutes}m`);
|
|
130
|
+
args.push(container);
|
|
131
|
+
const r = execSafe('docker', args);
|
|
132
|
+
if (!r.ok)
|
|
133
|
+
return { text: r.stderr, truncated: false };
|
|
134
|
+
let text = r.stdout;
|
|
135
|
+
if (opts.level) {
|
|
136
|
+
const pat = LEVEL_PATTERNS[opts.level];
|
|
137
|
+
text = text.split('\n').filter(l => pat.test(l) || l.trim() === '').join('\n');
|
|
138
|
+
}
|
|
139
|
+
if (opts.grep) {
|
|
140
|
+
const g = opts.grep;
|
|
141
|
+
text = text.split('\n').filter(l => l.includes(g)).join('\n');
|
|
142
|
+
}
|
|
143
|
+
const cap = opts.maxBytes ?? 200_000;
|
|
144
|
+
if (text.length > cap) {
|
|
145
|
+
return { text: text.slice(0, cap), truncated: true };
|
|
146
|
+
}
|
|
147
|
+
return { text, truncated: false };
|
|
148
|
+
}
|
package/dist/core/nginx.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
2
|
+
import { execSafe } from './exec.js';
|
|
3
|
+
import { assertDomain } from './validate.js';
|
|
3
4
|
const SITES_AVAILABLE = '/etc/nginx/sites-available';
|
|
4
5
|
const SITES_ENABLED = '/etc/nginx/sites-enabled';
|
|
5
6
|
export function listSites() {
|
|
@@ -15,14 +16,16 @@ export function listSites() {
|
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
18
|
export function installConfig(domain, content) {
|
|
19
|
+
assertDomain(domain);
|
|
18
20
|
const filename = `${domain}.conf`;
|
|
19
21
|
writeFileSync(`${SITES_AVAILABLE}/${filename}`, content);
|
|
20
22
|
const enabledPath = `${SITES_ENABLED}/${filename}`;
|
|
21
23
|
if (!existsSync(enabledPath)) {
|
|
22
|
-
|
|
24
|
+
execSafe('ln', ['-sf', `${SITES_AVAILABLE}/${filename}`, enabledPath]);
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
export function removeConfig(domain) {
|
|
28
|
+
assertDomain(domain);
|
|
26
29
|
const filename = `${domain}.conf`;
|
|
27
30
|
const available = `${SITES_AVAILABLE}/${filename}`;
|
|
28
31
|
const enabled = `${SITES_ENABLED}/${filename}`;
|
|
@@ -35,13 +38,14 @@ export function removeConfig(domain) {
|
|
|
35
38
|
return false;
|
|
36
39
|
}
|
|
37
40
|
export function testConfig() {
|
|
38
|
-
const result =
|
|
41
|
+
const result = execSafe('nginx', ['-t'], { timeout: 10_000 });
|
|
39
42
|
return { ok: result.ok || result.stderr.includes('successful'), output: result.stderr || result.stdout };
|
|
40
43
|
}
|
|
41
44
|
export function reload() {
|
|
42
|
-
return
|
|
45
|
+
return execSafe('systemctl', ['reload', 'nginx'], { timeout: 10_000 }).ok;
|
|
43
46
|
}
|
|
44
47
|
export function readConfig(domain) {
|
|
48
|
+
assertDomain(domain);
|
|
45
49
|
const path = `${SITES_AVAILABLE}/${domain}.conf`;
|
|
46
50
|
if (!existsSync(path))
|
|
47
51
|
return null;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface NotifyAdapterConfig {
|
|
2
|
+
type: 'bluebubbles' | 'telegram';
|
|
3
|
+
serverUrl?: string;
|
|
4
|
+
password?: string;
|
|
5
|
+
chatGuid?: string;
|
|
6
|
+
cfAccessClientId?: string;
|
|
7
|
+
cfAccessClientSecret?: string;
|
|
8
|
+
botToken?: string;
|
|
9
|
+
chatId?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface NotifyConfig {
|
|
12
|
+
adapters: NotifyAdapterConfig[];
|
|
13
|
+
}
|
|
14
|
+
export declare function loadNotifyConfig(): NotifyConfig | null;
|
|
15
|
+
export declare function sendNotification(config: NotifyConfig, message: string): Promise<boolean>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
const NOTIFY_CONFIG_PATH = '/etc/fleet/notify.json';
|
|
3
|
+
export function loadNotifyConfig() {
|
|
4
|
+
if (!existsSync(NOTIFY_CONFIG_PATH))
|
|
5
|
+
return null;
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(readFileSync(NOTIFY_CONFIG_PATH, 'utf-8'));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function sendNotification(config, message) {
|
|
14
|
+
let anySuccess = false;
|
|
15
|
+
for (const adapter of config.adapters) {
|
|
16
|
+
try {
|
|
17
|
+
const ok = adapter.type === 'bluebubbles'
|
|
18
|
+
? await sendBlueBubbles(adapter, message)
|
|
19
|
+
: await sendTelegram(adapter, message);
|
|
20
|
+
if (ok)
|
|
21
|
+
anySuccess = true;
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
console.error(`notify (${adapter.type}): ${err}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return anySuccess;
|
|
28
|
+
}
|
|
29
|
+
async function sendBlueBubbles(cfg, message) {
|
|
30
|
+
const url = `${cfg.serverUrl}/api/v1/message/text?password=${cfg.password}`;
|
|
31
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
32
|
+
if (cfg.cfAccessClientId) {
|
|
33
|
+
headers['CF-Access-Client-Id'] = cfg.cfAccessClientId;
|
|
34
|
+
headers['CF-Access-Client-Secret'] = cfg.cfAccessClientSecret;
|
|
35
|
+
}
|
|
36
|
+
const res = await fetch(url, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers,
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
chatGuid: cfg.chatGuid,
|
|
41
|
+
message,
|
|
42
|
+
tempGuid: `fleet-${Date.now()}`,
|
|
43
|
+
method: 'apple-script',
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
return res.ok;
|
|
47
|
+
}
|
|
48
|
+
async function sendTelegram(cfg, message) {
|
|
49
|
+
const res = await fetch(`https://api.telegram.org/bot${cfg.botToken}/sendMessage`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { 'Content-Type': 'application/json' },
|
|
52
|
+
body: JSON.stringify({ chat_id: cfg.chatId, text: message, parse_mode: 'HTML' }),
|
|
53
|
+
});
|
|
54
|
+
return res.ok;
|
|
55
|
+
}
|
package/dist/core/registry.d.ts
CHANGED
|
@@ -15,7 +15,32 @@ export interface AppEntry {
|
|
|
15
15
|
gitRepo?: string;
|
|
16
16
|
gitRemoteUrl?: string;
|
|
17
17
|
gitOnboardedAt?: string;
|
|
18
|
+
lastBuiltCommit?: string;
|
|
18
19
|
registeredAt: string;
|
|
20
|
+
frozenAt?: string;
|
|
21
|
+
frozenReason?: string;
|
|
22
|
+
/** Numeric UID/GID to chown /run/fleet-secrets/<app>/.env to after unseal.
|
|
23
|
+
* If unset, file remains root:root 0600 (the safe default). Used only for
|
|
24
|
+
* apps that read the env file directly from the host (rare); Docker apps
|
|
25
|
+
* using env_file in compose don't need this. */
|
|
26
|
+
runtimeUid?: number;
|
|
27
|
+
runtimeGid?: number;
|
|
28
|
+
/** Per-app age recipient public key (for fleet secrets harden --per-app).
|
|
29
|
+
* When set, the vault is encrypted to (admin + this) recipients. */
|
|
30
|
+
ageRecipient?: string;
|
|
31
|
+
/** Per-app log policy. If unset, defaults are applied (7 days, 100MB, info). */
|
|
32
|
+
logging?: {
|
|
33
|
+
retentionDays?: number;
|
|
34
|
+
maxSizeMB?: number;
|
|
35
|
+
level?: 'debug' | 'info' | 'warn' | 'error';
|
|
36
|
+
};
|
|
37
|
+
/** Per-app outbound egress allowlist. v1 supports `observe` and `shadow` modes
|
|
38
|
+
* only — `enforce` mode (actual drop via nftables) is deferred to Phase E. */
|
|
39
|
+
egress?: {
|
|
40
|
+
mode?: 'observe' | 'shadow';
|
|
41
|
+
/** Allowlist entries: 'host', 'host:port', or 'cidr/N'. Hosts resolved at check time. */
|
|
42
|
+
allow?: string[];
|
|
43
|
+
};
|
|
19
44
|
}
|
|
20
45
|
export interface Registry {
|
|
21
46
|
version: number;
|
package/dist/core/registry.js
CHANGED
|
@@ -1,29 +1,76 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, copyFileSync, renameSync, openSync, writeSync, fsyncSync, closeSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
-
|
|
5
|
+
function resolveRegistryPath() {
|
|
6
|
+
return process.env.FLEET_REGISTRY_PATH
|
|
7
|
+
?? join(__dirname, '..', '..', 'data', 'registry.json');
|
|
8
|
+
}
|
|
6
9
|
function defaultRegistry() {
|
|
7
10
|
return {
|
|
8
11
|
version: 1,
|
|
9
12
|
apps: [],
|
|
10
13
|
infrastructure: {
|
|
11
|
-
databases: { serviceName: 'docker-databases', composePath: '
|
|
14
|
+
databases: { serviceName: 'docker-databases', composePath: '' },
|
|
12
15
|
nginx: { configPath: '/etc/nginx' },
|
|
13
16
|
},
|
|
14
17
|
};
|
|
15
18
|
}
|
|
16
19
|
export function load() {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
const path = resolveRegistryPath();
|
|
21
|
+
const bakPath = path + '.bak';
|
|
22
|
+
if (existsSync(path)) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
process.stderr.write(`[registry] Warning: failed to parse ${path}, trying ${bakPath}\n`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (existsSync(bakPath)) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(bakPath, 'utf-8'));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
process.stderr.write(`[registry] Warning: failed to parse ${bakPath}, using default\n`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return defaultRegistry();
|
|
21
39
|
}
|
|
22
40
|
export function save(reg) {
|
|
23
|
-
const
|
|
41
|
+
const path = resolveRegistryPath();
|
|
42
|
+
const dir = dirname(path);
|
|
24
43
|
if (!existsSync(dir))
|
|
25
44
|
mkdirSync(dir, { recursive: true });
|
|
26
|
-
|
|
45
|
+
if (existsSync(path)) {
|
|
46
|
+
let mainIsValid = false;
|
|
47
|
+
try {
|
|
48
|
+
JSON.parse(readFileSync(path, 'utf-8'));
|
|
49
|
+
mainIsValid = true;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
process.stderr.write(`[registry] Warning: main registry unparsable, preserving existing .bak\n`);
|
|
53
|
+
}
|
|
54
|
+
if (mainIsValid) {
|
|
55
|
+
try {
|
|
56
|
+
copyFileSync(path, path + '.bak');
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
process.stderr.write(`[registry] Warning: failed to write .bak: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const tmp = path + '.tmp';
|
|
64
|
+
const data = JSON.stringify(reg, null, 2) + '\n';
|
|
65
|
+
const fd = openSync(tmp, 'w');
|
|
66
|
+
try {
|
|
67
|
+
writeSync(fd, data);
|
|
68
|
+
fsyncSync(fd);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
renameSync(tmp, path);
|
|
27
74
|
}
|
|
28
75
|
export function findApp(reg, name) {
|
|
29
76
|
return reg.apps.find(a => a.name === name || a.serviceName === name || a.displayName.toLowerCase() === name.toLowerCase());
|
|
@@ -43,5 +90,5 @@ export function removeApp(reg, name) {
|
|
|
43
90
|
return reg;
|
|
44
91
|
}
|
|
45
92
|
export function registryPath() {
|
|
46
|
-
return
|
|
93
|
+
return resolveRegistryPath();
|
|
47
94
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export interface CostRollup {
|
|
3
|
+
usdToday: number;
|
|
4
|
+
usdWeek: number;
|
|
5
|
+
usdMonth: number;
|
|
6
|
+
runsToday: number;
|
|
7
|
+
runsWeek: number;
|
|
8
|
+
runsMonth: number;
|
|
9
|
+
}
|
|
10
|
+
export interface CostByRoutine {
|
|
11
|
+
routineId: string;
|
|
12
|
+
runs: number;
|
|
13
|
+
usd: number;
|
|
14
|
+
inputTokens: number;
|
|
15
|
+
outputTokens: number;
|
|
16
|
+
}
|
|
17
|
+
export interface DailyCostBucket {
|
|
18
|
+
date: string;
|
|
19
|
+
usd: number;
|
|
20
|
+
runs: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function costRollup(db: Database.Database): CostRollup;
|
|
23
|
+
export declare function costByRoutine(db: Database.Database, days?: number, limit?: number): CostByRoutine[];
|
|
24
|
+
export declare function dailyCostSeries(db: Database.Database, days?: number): DailyCostBucket[];
|