@virtengine/openfleet 0.25.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/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- package/worktree-manager.mjs +1266 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
* VirtEngine Control Center – Telegram SDK Wrapper
|
|
3
|
+
* Enhanced Telegram Mini App SDK integration
|
|
4
|
+
* ────────────────────────────────────────────────────────────── */
|
|
5
|
+
|
|
6
|
+
import { signal } from "@preact/signals";
|
|
7
|
+
|
|
8
|
+
/* ─── Core Accessor ─── */
|
|
9
|
+
|
|
10
|
+
/** Get the Telegram WebApp instance, or null outside Telegram */
|
|
11
|
+
export function getTg() {
|
|
12
|
+
return globalThis.Telegram?.WebApp || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether the app is running inside a Telegram WebView */
|
|
16
|
+
export const isTelegramContext = !!getTg();
|
|
17
|
+
|
|
18
|
+
/** Reactive color scheme signal ('light' | 'dark') */
|
|
19
|
+
export const colorScheme = signal(getTg()?.colorScheme || "dark");
|
|
20
|
+
|
|
21
|
+
/* ─── Haptic Feedback ─── */
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Trigger haptic feedback.
|
|
25
|
+
* @param {'light'|'medium'|'heavy'|'rigid'|'soft'} type
|
|
26
|
+
*/
|
|
27
|
+
export function haptic(type = "light") {
|
|
28
|
+
try {
|
|
29
|
+
getTg()?.HapticFeedback?.impactOccurred(type);
|
|
30
|
+
} catch {
|
|
31
|
+
/* noop outside Telegram */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ─── Initialization ─── */
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Full Telegram WebApp initialization – call once at app mount.
|
|
39
|
+
* Expands the viewport, enables fullscreen, disables vertical swipes,
|
|
40
|
+
* sets header/background/bottom-bar colors, etc.
|
|
41
|
+
*/
|
|
42
|
+
export function initTelegramApp() {
|
|
43
|
+
const tg = getTg();
|
|
44
|
+
if (!tg) return;
|
|
45
|
+
|
|
46
|
+
tg.ready();
|
|
47
|
+
tg.expand();
|
|
48
|
+
|
|
49
|
+
// Bot API 8.0+ fullscreen — only on mobile (desktop Telegram doesn't need it)
|
|
50
|
+
const platform = (tg.platform || "").toLowerCase();
|
|
51
|
+
const isMobile = platform === "ios" || platform === "android" || platform === "android_x";
|
|
52
|
+
if (isMobile) {
|
|
53
|
+
try {
|
|
54
|
+
tg.requestFullscreen?.();
|
|
55
|
+
} catch {
|
|
56
|
+
/* not supported */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Bot API 7.7+ disable vertical swipes for custom scroll
|
|
61
|
+
try {
|
|
62
|
+
tg.disableVerticalSwipes?.();
|
|
63
|
+
} catch {
|
|
64
|
+
/* not supported */
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Closing confirmation
|
|
68
|
+
try {
|
|
69
|
+
tg.enableClosingConfirmation?.();
|
|
70
|
+
} catch {
|
|
71
|
+
/* not supported */
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Apply colours
|
|
75
|
+
try {
|
|
76
|
+
tg.setHeaderColor?.("secondary_bg_color");
|
|
77
|
+
tg.setBackgroundColor?.("bg_color");
|
|
78
|
+
tg.setBottomBarColor?.("secondary_bg_color");
|
|
79
|
+
} catch {
|
|
80
|
+
/* not supported */
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Apply theme params to CSS custom properties
|
|
84
|
+
applyTgTheme();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Map Telegram themeParams to CSS custom properties on :root */
|
|
88
|
+
function applyTgTheme() {
|
|
89
|
+
const tg = getTg();
|
|
90
|
+
if (!tg?.themeParams) return;
|
|
91
|
+
|
|
92
|
+
const tp = tg.themeParams;
|
|
93
|
+
const root = document.documentElement;
|
|
94
|
+
root.setAttribute("data-tg-theme", "true");
|
|
95
|
+
|
|
96
|
+
if (tp.bg_color) root.style.setProperty("--bg-primary", tp.bg_color);
|
|
97
|
+
if (tp.secondary_bg_color) {
|
|
98
|
+
root.style.setProperty("--bg-secondary", tp.secondary_bg_color);
|
|
99
|
+
root.style.setProperty("--bg-card", tp.secondary_bg_color);
|
|
100
|
+
}
|
|
101
|
+
if (tp.text_color) root.style.setProperty("--text-primary", tp.text_color);
|
|
102
|
+
if (tp.hint_color) {
|
|
103
|
+
root.style.setProperty("--text-secondary", tp.hint_color);
|
|
104
|
+
root.style.setProperty("--text-hint", tp.hint_color);
|
|
105
|
+
}
|
|
106
|
+
if (tp.link_color) root.style.setProperty("--accent", tp.link_color);
|
|
107
|
+
if (tp.button_color) root.style.setProperty("--accent", tp.button_color);
|
|
108
|
+
if (tp.button_text_color)
|
|
109
|
+
root.style.setProperty("--accent-text", tp.button_text_color);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ─── Event Listeners ─── */
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Subscribe to theme changes. Returns an unsubscribe function.
|
|
116
|
+
* @param {() => void} callback
|
|
117
|
+
* @returns {() => void}
|
|
118
|
+
*/
|
|
119
|
+
export function onThemeChange(callback) {
|
|
120
|
+
const tg = getTg();
|
|
121
|
+
if (!tg) return () => {};
|
|
122
|
+
const handler = () => {
|
|
123
|
+
applyTgTheme();
|
|
124
|
+
callback();
|
|
125
|
+
};
|
|
126
|
+
tg.onEvent("themeChanged", handler);
|
|
127
|
+
return () => tg.offEvent("themeChanged", handler);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Subscribe to viewport changes. Returns an unsubscribe function.
|
|
132
|
+
* @param {(event: {isStateStable: boolean}) => void} callback
|
|
133
|
+
* @returns {() => void}
|
|
134
|
+
*/
|
|
135
|
+
export function onViewportChange(callback) {
|
|
136
|
+
const tg = getTg();
|
|
137
|
+
if (!tg) return () => {};
|
|
138
|
+
tg.onEvent("viewportChanged", callback);
|
|
139
|
+
return () => tg.offEvent("viewportChanged", callback);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ─── MainButton Helpers ─── */
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Show the Telegram MainButton with given text and handler.
|
|
146
|
+
* @param {string} text
|
|
147
|
+
* @param {() => void} onClick
|
|
148
|
+
* @param {{color?: string, textColor?: string, progress?: boolean}} options
|
|
149
|
+
*/
|
|
150
|
+
export function showMainButton(text, onClick, options = {}) {
|
|
151
|
+
const tg = getTg();
|
|
152
|
+
if (!tg?.MainButton) return;
|
|
153
|
+
tg.MainButton.setText(text);
|
|
154
|
+
if (options.color) tg.MainButton.color = options.color;
|
|
155
|
+
if (options.textColor) tg.MainButton.textColor = options.textColor;
|
|
156
|
+
tg.MainButton.onClick(onClick);
|
|
157
|
+
tg.MainButton.show();
|
|
158
|
+
if (options.progress) tg.MainButton.showProgress();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Hide the Telegram MainButton and clear its handler. */
|
|
162
|
+
export function hideMainButton() {
|
|
163
|
+
const tg = getTg();
|
|
164
|
+
if (!tg?.MainButton) return;
|
|
165
|
+
tg.MainButton.hide();
|
|
166
|
+
tg.MainButton.hideProgress();
|
|
167
|
+
try {
|
|
168
|
+
tg.MainButton.offClick(tg.MainButton._callback);
|
|
169
|
+
} catch {
|
|
170
|
+
/* noop */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ─── BackButton Helpers ─── */
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Show the Telegram BackButton with the given handler.
|
|
178
|
+
* @param {() => void} onClick
|
|
179
|
+
*/
|
|
180
|
+
export function showBackButton(onClick) {
|
|
181
|
+
const tg = getTg();
|
|
182
|
+
if (!tg?.BackButton) return;
|
|
183
|
+
tg.BackButton.onClick(onClick);
|
|
184
|
+
tg.BackButton.show();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Hide the Telegram BackButton and clear its handler. */
|
|
188
|
+
export function hideBackButton() {
|
|
189
|
+
const tg = getTg();
|
|
190
|
+
if (!tg?.BackButton) return;
|
|
191
|
+
tg.BackButton.hide();
|
|
192
|
+
try {
|
|
193
|
+
tg.BackButton.offClick(tg.BackButton._callback);
|
|
194
|
+
} catch {
|
|
195
|
+
/* noop */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ─── SettingsButton ─── */
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Show the Telegram SettingsButton (header gear icon).
|
|
203
|
+
* @param {() => void} onClick
|
|
204
|
+
*/
|
|
205
|
+
export function showSettingsButton(onClick) {
|
|
206
|
+
const tg = getTg();
|
|
207
|
+
if (!tg?.SettingsButton) return;
|
|
208
|
+
tg.SettingsButton.onClick(onClick);
|
|
209
|
+
tg.SettingsButton.show();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* ─── Cloud Storage ─── */
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Read a value from Telegram Cloud Storage.
|
|
216
|
+
* @param {string} key
|
|
217
|
+
* @returns {Promise<string|null>}
|
|
218
|
+
*/
|
|
219
|
+
export async function cloudStorageGet(key) {
|
|
220
|
+
const tg = getTg();
|
|
221
|
+
if (!tg?.CloudStorage) return null;
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
tg.CloudStorage.getItem(key, (err, val) => {
|
|
224
|
+
if (err) {
|
|
225
|
+
resolve(null);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
resolve(val ?? null);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Write a value to Telegram Cloud Storage.
|
|
235
|
+
* @param {string} key
|
|
236
|
+
* @param {string} value
|
|
237
|
+
* @returns {Promise<boolean>}
|
|
238
|
+
*/
|
|
239
|
+
export async function cloudStorageSet(key, value) {
|
|
240
|
+
const tg = getTg();
|
|
241
|
+
if (!tg?.CloudStorage) return false;
|
|
242
|
+
return new Promise((resolve) => {
|
|
243
|
+
tg.CloudStorage.setItem(key, value, (err) => {
|
|
244
|
+
resolve(!err);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Remove a key from Telegram Cloud Storage.
|
|
251
|
+
* @param {string} key
|
|
252
|
+
* @returns {Promise<boolean>}
|
|
253
|
+
*/
|
|
254
|
+
export async function cloudStorageRemove(key) {
|
|
255
|
+
const tg = getTg();
|
|
256
|
+
if (!tg?.CloudStorage) return false;
|
|
257
|
+
return new Promise((resolve) => {
|
|
258
|
+
tg.CloudStorage.removeItem(key, (err) => {
|
|
259
|
+
resolve(!err);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* ─── Auth / User ─── */
|
|
265
|
+
|
|
266
|
+
/** Get the raw initData string for server-side validation. */
|
|
267
|
+
export function getInitData() {
|
|
268
|
+
return getTg()?.initData || "";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Get the current Telegram user object, or null. */
|
|
272
|
+
export function getTelegramUser() {
|
|
273
|
+
return getTg()?.initDataUnsafe?.user || null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* ─── Native Dialogs ─── */
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Show a native Telegram confirm dialog (falls back to window.confirm).
|
|
280
|
+
* @param {string} message
|
|
281
|
+
* @returns {Promise<boolean>}
|
|
282
|
+
*/
|
|
283
|
+
export function showConfirm(message) {
|
|
284
|
+
return new Promise((resolve) => {
|
|
285
|
+
const tg = getTg();
|
|
286
|
+
if (!tg?.showConfirm) {
|
|
287
|
+
resolve(window.confirm(message));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
tg.showConfirm(message, resolve);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Show a native Telegram alert dialog (falls back to window.alert).
|
|
296
|
+
* @param {string} message
|
|
297
|
+
* @returns {Promise<void>}
|
|
298
|
+
*/
|
|
299
|
+
export function showAlert(message) {
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
const tg = getTg();
|
|
302
|
+
if (!tg?.showAlert) {
|
|
303
|
+
window.alert(message);
|
|
304
|
+
resolve();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
tg.showAlert(message, resolve);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* ─── External Links ─── */
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Open a URL in the external browser via Telegram, or fallback.
|
|
315
|
+
* @param {string} url
|
|
316
|
+
*/
|
|
317
|
+
export function openLink(url) {
|
|
318
|
+
const tg = getTg();
|
|
319
|
+
if (tg?.openLink) {
|
|
320
|
+
tg.openLink(url);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
window.open(url, "_blank");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* ─── Platform ─── */
|
|
327
|
+
|
|
328
|
+
/** Return the current Telegram platform string (e.g. 'android', 'ios', 'tdesktop'). */
|
|
329
|
+
export function getPlatform() {
|
|
330
|
+
return getTg()?.platform || "unknown";
|
|
331
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
* VirtEngine Control Center – Utility Helpers
|
|
3
|
+
* Pure functions – no framework imports needed
|
|
4
|
+
* ────────────────────────────────────────────────────────────── */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format a Date (or ISO string) to a locale-aware string.
|
|
8
|
+
* @param {Date|string|number} d
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function formatDate(d) {
|
|
12
|
+
if (!d) return "—";
|
|
13
|
+
try {
|
|
14
|
+
const date = d instanceof Date ? d : new Date(d);
|
|
15
|
+
if (isNaN(date.getTime())) return String(d);
|
|
16
|
+
return date.toLocaleString(undefined, {
|
|
17
|
+
year: "numeric",
|
|
18
|
+
month: "short",
|
|
19
|
+
day: "numeric",
|
|
20
|
+
hour: "2-digit",
|
|
21
|
+
minute: "2-digit",
|
|
22
|
+
});
|
|
23
|
+
} catch {
|
|
24
|
+
return String(d);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Format a Date to a relative "Xm ago"/"Xh ago"/"Xd ago" string.
|
|
30
|
+
* @param {Date|string|number} d
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function formatRelative(d) {
|
|
34
|
+
if (d === null || d === undefined || d === "") return "—";
|
|
35
|
+
try {
|
|
36
|
+
let date;
|
|
37
|
+
if (d instanceof Date) {
|
|
38
|
+
date = d;
|
|
39
|
+
} else if (typeof d === "number") {
|
|
40
|
+
if (!Number.isFinite(d)) return "—";
|
|
41
|
+
const normalized = Math.abs(d) < 1e12 ? d * 1000 : d;
|
|
42
|
+
date = new Date(normalized);
|
|
43
|
+
} else {
|
|
44
|
+
date = new Date(d);
|
|
45
|
+
}
|
|
46
|
+
const timestamp = date.getTime();
|
|
47
|
+
if (!Number.isFinite(timestamp)) return "—";
|
|
48
|
+
const diffMs = Date.now() - timestamp;
|
|
49
|
+
if (!Number.isFinite(diffMs)) return "—";
|
|
50
|
+
if (diffMs < 0) return "just now";
|
|
51
|
+
const seconds = Math.floor(diffMs / 1000);
|
|
52
|
+
if (!Number.isFinite(seconds)) return "—";
|
|
53
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
54
|
+
const minutes = Math.floor(seconds / 60);
|
|
55
|
+
if (!Number.isFinite(minutes)) return "—";
|
|
56
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
57
|
+
const hours = Math.floor(minutes / 60);
|
|
58
|
+
if (!Number.isFinite(hours)) return "—";
|
|
59
|
+
if (hours < 24) return `${hours}h ago`;
|
|
60
|
+
const days = Math.floor(hours / 24);
|
|
61
|
+
if (!Number.isFinite(days)) return "—";
|
|
62
|
+
if (days < 30) return `${days}d ago`;
|
|
63
|
+
const months = Math.floor(days / 30);
|
|
64
|
+
if (!Number.isFinite(months)) return "—";
|
|
65
|
+
if (months < 12) return `${months}mo ago`;
|
|
66
|
+
const years = Math.floor(months / 12);
|
|
67
|
+
if (!Number.isFinite(years)) return "—";
|
|
68
|
+
return `${years}y ago`;
|
|
69
|
+
} catch {
|
|
70
|
+
return "—";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Format milliseconds to a human-readable duration: "Xm Ys" or "Xh Ym".
|
|
76
|
+
* @param {number} ms
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function formatDuration(ms) {
|
|
80
|
+
if (ms == null || isNaN(ms)) return "—";
|
|
81
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
82
|
+
const totalSec = Math.floor(ms / 1000);
|
|
83
|
+
if (totalSec < 60) return `${totalSec}s`;
|
|
84
|
+
const minutes = Math.floor(totalSec / 60);
|
|
85
|
+
const seconds = totalSec % 60;
|
|
86
|
+
if (minutes < 60) return `${minutes}m ${seconds}s`;
|
|
87
|
+
const hours = Math.floor(minutes / 60);
|
|
88
|
+
const remainMin = minutes % 60;
|
|
89
|
+
return `${hours}h ${remainMin}m`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Truncate a string, appending "…" if it exceeds maxLen.
|
|
94
|
+
* @param {string} str
|
|
95
|
+
* @param {number} maxLen
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
export function truncate(str, maxLen = 60) {
|
|
99
|
+
if (!str) return "";
|
|
100
|
+
if (str.length <= maxLen) return str;
|
|
101
|
+
return str.slice(0, maxLen - 1) + "…";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Debounce a function.
|
|
106
|
+
* @param {Function} fn
|
|
107
|
+
* @param {number} ms
|
|
108
|
+
* @returns {Function}
|
|
109
|
+
*/
|
|
110
|
+
export function debounce(fn, ms = 300) {
|
|
111
|
+
let timer = null;
|
|
112
|
+
const debounced = (...args) => {
|
|
113
|
+
if (timer) clearTimeout(timer);
|
|
114
|
+
timer = setTimeout(() => {
|
|
115
|
+
timer = null;
|
|
116
|
+
fn(...args);
|
|
117
|
+
}, ms);
|
|
118
|
+
};
|
|
119
|
+
debounced.cancel = () => {
|
|
120
|
+
if (timer) {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
timer = null;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
return debounced;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Deep clone a value via JSON round-trip. Returns null on failure.
|
|
130
|
+
* Prefers structuredClone when available.
|
|
131
|
+
* @param {*} v
|
|
132
|
+
* @returns {*}
|
|
133
|
+
*/
|
|
134
|
+
export function cloneValue(v) {
|
|
135
|
+
if (v === null || v === undefined) return v;
|
|
136
|
+
try {
|
|
137
|
+
if (typeof structuredClone === "function") return structuredClone(v);
|
|
138
|
+
return JSON.parse(JSON.stringify(v));
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Format a byte count to a human-readable string.
|
|
146
|
+
* @param {number} bytes
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
export function formatBytes(bytes) {
|
|
150
|
+
if (bytes == null || isNaN(bytes)) return "—";
|
|
151
|
+
if (bytes === 0) return "0 B";
|
|
152
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
153
|
+
const k = 1024;
|
|
154
|
+
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k));
|
|
155
|
+
const idx = Math.min(i, units.length - 1);
|
|
156
|
+
const value = bytes / Math.pow(k, idx);
|
|
157
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[idx]}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pluralize a word based on count.
|
|
162
|
+
* @param {number} count
|
|
163
|
+
* @param {string} singular
|
|
164
|
+
* @param {string} [plural]
|
|
165
|
+
* @returns {string}
|
|
166
|
+
*/
|
|
167
|
+
export function pluralize(count, singular, plural) {
|
|
168
|
+
const p = plural || `${singular}s`;
|
|
169
|
+
return `${count} ${count === 1 ? singular : p}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Generate a simple unique ID (not crypto-grade).
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
export function generateId() {
|
|
177
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Promisified setTimeout.
|
|
182
|
+
* @param {number} ms
|
|
183
|
+
* @returns {Promise<void>}
|
|
184
|
+
*/
|
|
185
|
+
export function sleep(ms) {
|
|
186
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Conditional class name builder (similar to clsx / classnames).
|
|
191
|
+
* Accepts strings, objects { className: boolean }, and arrays.
|
|
192
|
+
* @param {...(string|object|Array|null|undefined|false)} args
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
export function classNames(...args) {
|
|
196
|
+
const classes = [];
|
|
197
|
+
for (const arg of args) {
|
|
198
|
+
if (!arg) continue;
|
|
199
|
+
if (typeof arg === "string") {
|
|
200
|
+
classes.push(arg);
|
|
201
|
+
} else if (Array.isArray(arg)) {
|
|
202
|
+
const inner = classNames(...arg);
|
|
203
|
+
if (inner) classes.push(inner);
|
|
204
|
+
} else if (typeof arg === "object") {
|
|
205
|
+
for (const [key, val] of Object.entries(arg)) {
|
|
206
|
+
if (val) classes.push(key);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return classes.join(" ");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* ─── Data Export Utilities ─── */
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Trigger a file download from in-memory content.
|
|
217
|
+
* @param {string} content
|
|
218
|
+
* @param {string} filename
|
|
219
|
+
* @param {string} [mimeType='text/plain']
|
|
220
|
+
*/
|
|
221
|
+
export function downloadFile(content, filename, mimeType = "text/plain") {
|
|
222
|
+
const blob = new Blob([content], { type: mimeType });
|
|
223
|
+
const url = URL.createObjectURL(blob);
|
|
224
|
+
const a = document.createElement("a");
|
|
225
|
+
a.href = url;
|
|
226
|
+
a.download = filename;
|
|
227
|
+
document.body.appendChild(a);
|
|
228
|
+
a.click();
|
|
229
|
+
document.body.removeChild(a);
|
|
230
|
+
URL.revokeObjectURL(url);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Escape a value for CSV (RFC 4180).
|
|
235
|
+
* @param {*} val
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
function csvEscape(val) {
|
|
239
|
+
const str = val == null ? "" : String(val);
|
|
240
|
+
if (/[",\n\r]/.test(str)) {
|
|
241
|
+
return '"' + str.replace(/"/g, '""') + '"';
|
|
242
|
+
}
|
|
243
|
+
return str;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Export tabular data as a CSV file download.
|
|
248
|
+
* Adds a UTF-8 BOM for Excel compatibility.
|
|
249
|
+
* @param {string[]} headers
|
|
250
|
+
* @param {Array<Array<*>>} rows
|
|
251
|
+
* @param {string} filename
|
|
252
|
+
*/
|
|
253
|
+
export function exportAsCSV(headers, rows, filename) {
|
|
254
|
+
const lines = [headers.map(csvEscape).join(",")];
|
|
255
|
+
for (const row of rows) {
|
|
256
|
+
lines.push(row.map(csvEscape).join(","));
|
|
257
|
+
}
|
|
258
|
+
const csv = "\uFEFF" + lines.join("\r\n");
|
|
259
|
+
downloadFile(csv, filename, "text/csv");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Export data as a pretty-printed JSON file download.
|
|
264
|
+
* @param {*} data
|
|
265
|
+
* @param {string} filename
|
|
266
|
+
*/
|
|
267
|
+
export function exportAsJSON(data, filename) {
|
|
268
|
+
const json = JSON.stringify(data, null, 2);
|
|
269
|
+
downloadFile(json, filename, "application/json");
|
|
270
|
+
}
|