@flame0510/project-aether 1.5.2 → 1.6.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/app/api/gateway/provider/route.ts +16 -10
- package/app/api/models/details/route.ts +30 -0
- package/app/api/system-health/route.ts +3 -3
- package/app/api/update-check/route.ts +27 -11
- package/app/components/SessionDrawer.tsx +4 -4
- package/app/components/VersionBanner.tsx +101 -56
- package/app/components/ui/Metric.tsx +7 -2
- package/app/gateway/ModelDetailsModal.tsx +322 -0
- package/app/gateway/PageClient.tsx +28 -10
- package/app/globals.css +4 -1
- package/app/lib/model-format.ts +21 -0
- package/docs/FRONTEND-ARCHITECTURE.md +2 -1
- package/docs/dev/API-REFERENCE.md +41 -2
- package/docs/dev/GATEWAY.md +58 -0
- package/lib/memory-context.ts +2 -2
- package/lib/model-catalogue.ts +17 -0
- package/lib/model-details.ts +124 -0
- package/lib/provider-labels.ts +21 -0
- package/model-details.json +16360 -0
- package/model-pricing.json +59 -59
- package/models.config.json +75 -9
- package/package.json +4 -2
- package/scripts/check-language.mjs +76 -0
- package/scripts/lib/model-upstream.mjs +61 -0
- package/scripts/model-info-suggest.mjs +149 -0
- package/scripts/refresh-model-pricing.mjs +175 -16
|
@@ -11,6 +11,8 @@ import { readProviderKeys, writeProviderKeys } from './keys';
|
|
|
11
11
|
import { dockerUnreachableReason, syncAllAgents, type SyncOutcome } from '../sync';
|
|
12
12
|
import { detectProviderGatewayUrl } from '@/lib/docker-utils';
|
|
13
13
|
import { getPricing } from '@/lib/model-pricing';
|
|
14
|
+
import { getModelParams } from '@/lib/model-details';
|
|
15
|
+
import { PROVIDER_LABELS } from '@/lib/provider-labels';
|
|
14
16
|
import { loadModelsConfig, toggleModelOverride, type ModelConfigEntry } from '@/lib/model-catalogue';
|
|
15
17
|
import { getLivePricing } from '@/lib/openrouter-pricing';
|
|
16
18
|
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
@@ -33,70 +35,70 @@ interface ProviderEnvConfig {
|
|
|
33
35
|
const PROVIDER_CONFIGS: Record<string, ProviderEnvConfig> = {
|
|
34
36
|
google: {
|
|
35
37
|
envKey: 'PROVIDER_GOOGLE_API_KEY',
|
|
36
|
-
label:
|
|
38
|
+
label: PROVIDER_LABELS.google,
|
|
37
39
|
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
38
40
|
authHeader: 'x-goog-api-key',
|
|
39
41
|
docsUrl: 'https://aistudio.google.com/app/apikey',
|
|
40
42
|
},
|
|
41
43
|
deepseek: {
|
|
42
44
|
envKey: 'PROVIDER_DEEPSEEK_API_KEY',
|
|
43
|
-
label:
|
|
45
|
+
label: PROVIDER_LABELS.deepseek,
|
|
44
46
|
baseUrl: 'https://api.deepseek.com',
|
|
45
47
|
authHeader: 'Authorization',
|
|
46
48
|
docsUrl: 'https://platform.deepseek.com/api_keys',
|
|
47
49
|
},
|
|
48
50
|
openrouter: {
|
|
49
51
|
envKey: 'PROVIDER_OPENROUTER_API_KEY',
|
|
50
|
-
label:
|
|
52
|
+
label: PROVIDER_LABELS.openrouter,
|
|
51
53
|
baseUrl: 'https://openrouter.ai/api',
|
|
52
54
|
authHeader: 'Authorization',
|
|
53
55
|
docsUrl: 'https://openrouter.ai/keys',
|
|
54
56
|
},
|
|
55
57
|
openai: {
|
|
56
58
|
envKey: 'PROVIDER_OPENAI_API_KEY',
|
|
57
|
-
label:
|
|
59
|
+
label: PROVIDER_LABELS.openai,
|
|
58
60
|
baseUrl: 'https://api.openai.com',
|
|
59
61
|
authHeader: 'Authorization',
|
|
60
62
|
docsUrl: 'https://platform.openai.com/api-keys',
|
|
61
63
|
},
|
|
62
64
|
anthropic: {
|
|
63
65
|
envKey: 'PROVIDER_ANTHROPIC_API_KEY',
|
|
64
|
-
label:
|
|
66
|
+
label: PROVIDER_LABELS.anthropic,
|
|
65
67
|
baseUrl: 'https://api.anthropic.com',
|
|
66
68
|
authHeader: 'x-api-key',
|
|
67
69
|
docsUrl: 'https://console.anthropic.com/settings/keys',
|
|
68
70
|
},
|
|
69
71
|
groq: {
|
|
70
72
|
envKey: 'PROVIDER_GROQ_API_KEY',
|
|
71
|
-
label:
|
|
73
|
+
label: PROVIDER_LABELS.groq,
|
|
72
74
|
baseUrl: 'https://api.groq.com/openai',
|
|
73
75
|
authHeader: 'Authorization',
|
|
74
76
|
docsUrl: 'https://console.groq.com/keys',
|
|
75
77
|
},
|
|
76
78
|
kimi: {
|
|
77
79
|
envKey: 'PROVIDER_KIMI_API_KEY',
|
|
78
|
-
label:
|
|
80
|
+
label: PROVIDER_LABELS.kimi,
|
|
79
81
|
baseUrl: 'https://api.moonshot.cn',
|
|
80
82
|
authHeader: 'Authorization',
|
|
81
83
|
docsUrl: 'https://platform.kimi.com/console/api-keys',
|
|
82
84
|
},
|
|
83
85
|
glm: {
|
|
84
86
|
envKey: 'PROVIDER_GLM_API_KEY',
|
|
85
|
-
label:
|
|
87
|
+
label: PROVIDER_LABELS.glm,
|
|
86
88
|
baseUrl: 'https://api.z.ai',
|
|
87
89
|
authHeader: 'Authorization',
|
|
88
90
|
docsUrl: 'https://z.ai/manage-apikey/apikey-list',
|
|
89
91
|
},
|
|
90
92
|
qwen: {
|
|
91
93
|
envKey: 'PROVIDER_QWEN_API_KEY',
|
|
92
|
-
label:
|
|
94
|
+
label: PROVIDER_LABELS.qwen,
|
|
93
95
|
baseUrl: 'https://dashscope.aliyuncs.com',
|
|
94
96
|
authHeader: 'Authorization',
|
|
95
97
|
docsUrl: 'https://bailian.console.aliyun.com/cn-beijing?tab=model#/api-key',
|
|
96
98
|
},
|
|
97
99
|
rev4a: {
|
|
98
100
|
envKey: 'REV4A_API_KEY',
|
|
99
|
-
label:
|
|
101
|
+
label: PROVIDER_LABELS.rev4a,
|
|
100
102
|
baseUrl: detectProviderGatewayUrl(),
|
|
101
103
|
authHeader: 'Authorization',
|
|
102
104
|
docsUrl: '',
|
|
@@ -167,6 +169,9 @@ export async function GET(request: NextRequest) {
|
|
|
167
169
|
// containers from it, so taking a different one upstream would advertise
|
|
168
170
|
// inputs the agent was never configured to accept.
|
|
169
171
|
const livePricing = await getLivePricing(allModels.map((m) => m.id));
|
|
172
|
+
// Sizes for the row's "next to the price" hint: open-weight models only (see
|
|
173
|
+
// docs/dev/GATEWAY.md), absent for a model whose vendor does not publish one.
|
|
174
|
+
const paramsByModel = getModelParams();
|
|
170
175
|
|
|
171
176
|
const providers = Object.entries(PROVIDER_CONFIGS).map(([key, cfg]) => {
|
|
172
177
|
const providerModels = allModels.filter((m) => m.provider === key);
|
|
@@ -184,6 +189,7 @@ export async function GET(request: NextRequest) {
|
|
|
184
189
|
if (staticPrice) entry.pricing = staticPrice;
|
|
185
190
|
}
|
|
186
191
|
if (m.modality) entry.modality = m.modality;
|
|
192
|
+
if (paramsByModel[m.id]) entry.params = paramsByModel[m.id];
|
|
187
193
|
return entry;
|
|
188
194
|
}),
|
|
189
195
|
...(providerKeys[key] ? { apiKey: providerKeys[key] } : {}),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/models/details?id=<model id>
|
|
3
|
+
*
|
|
4
|
+
* Everything the details modal shows for one model: the curated catalogue entry, the
|
|
5
|
+
* price (and where it is from) and the generated specs, description and benchmarks
|
|
6
|
+
* (`model-details.json`). Fetched when the modal opens rather than inlined in
|
|
7
|
+
* `/api/models`, which pages poll: descriptions are long and only one model is looked at
|
|
8
|
+
* at a time.
|
|
9
|
+
*
|
|
10
|
+
* Absent fields are simply absent — the modal hides those blocks instead of printing
|
|
11
|
+
* "n/a" — and a model the catalogue does not carry is a plain 404.
|
|
12
|
+
*/
|
|
13
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
14
|
+
import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
15
|
+
import { getModelDetails } from '@/lib/model-details';
|
|
16
|
+
|
|
17
|
+
export const dynamic = 'force-dynamic';
|
|
18
|
+
|
|
19
|
+
export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
20
|
+
const denied = await requireAuthJWT(request);
|
|
21
|
+
if (denied) return denied;
|
|
22
|
+
|
|
23
|
+
const id = request.nextUrl.searchParams.get('id');
|
|
24
|
+
if (!id) return NextResponse.json({ error: 'id query param required' }, { status: 400 });
|
|
25
|
+
|
|
26
|
+
const details = getModelDetails(id);
|
|
27
|
+
if (!details) return NextResponse.json({ error: `No model '${id}' in the catalogue` }, { status: 404 });
|
|
28
|
+
|
|
29
|
+
return NextResponse.json(details, { headers: { 'Cache-Control': 'no-store' } });
|
|
30
|
+
}
|
|
@@ -106,7 +106,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
106
106
|
? 'Heartbeat metrics unavailable in this DB'
|
|
107
107
|
: latestMetricAgeSeconds === null
|
|
108
108
|
? 'No heartbeat metrics detected'
|
|
109
|
-
: `
|
|
109
|
+
: `last heartbeat metrics ${latestMetricAgeSeconds}s ago`,
|
|
110
110
|
source: 'runtime',
|
|
111
111
|
});
|
|
112
112
|
|
|
@@ -157,7 +157,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
157
157
|
severity: 'warning',
|
|
158
158
|
source: 'runtime',
|
|
159
159
|
title: 'Review recent errors',
|
|
160
|
-
details: `${eventErrorCount} error events
|
|
160
|
+
details: `${eventErrorCount} error events in the last 24h.`,
|
|
161
161
|
actionHref: '/lineage',
|
|
162
162
|
dismissible: true,
|
|
163
163
|
createdAt,
|
|
@@ -181,7 +181,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|
|
181
181
|
label: 'Usage-based cost today',
|
|
182
182
|
health: billing.usageBasedCost > 10 ? 'warning' : 'ok',
|
|
183
183
|
value: `$${billing.usageBasedCost.toFixed(2)}`,
|
|
184
|
-
details: `DB estimate $${billing.dbEstimatedCost.toFixed(2)}; fixed ${billing.buckets.fixed.sessions}
|
|
184
|
+
details: `DB estimate $${billing.dbEstimatedCost.toFixed(2)}; fixed ${billing.buckets.fixed.sessions} sessions; credits ${billing.buckets.credits.sessions} sessions`,
|
|
185
185
|
source: 'cost',
|
|
186
186
|
});
|
|
187
187
|
if (billing.usageBasedCost > 10) {
|
|
@@ -8,15 +8,26 @@ import { requireAuthJWT } from '@/lib/rev4a-auth';
|
|
|
8
8
|
// package name is the single source of truth, so never hardcode it elsewhere.
|
|
9
9
|
const FALLBACK_PKG_NAME = '@flame0510/project-aether';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The installed package's name and version. `version` is **null when it cannot be read**
|
|
13
|
+
* — never a placeholder like `0.0.0`: during `rev4a update` npm rewrites this very file,
|
|
14
|
+
* and a transient read failure must not look like a version that changed.
|
|
15
|
+
*/
|
|
16
|
+
function getPackageInfo(): { version: string | null; name: string } {
|
|
17
|
+
// cwd first (systemd sets WorkingDirectory), then relative to this module: a global
|
|
18
|
+
// install started from elsewhere still finds its own package.json.
|
|
19
|
+
const candidates = [join(process.cwd(), 'package.json'), join(__dirname, '..', '..', '..', 'package.json')];
|
|
20
|
+
for (const pkgPath of candidates) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: unknown; name?: unknown };
|
|
23
|
+
const version = typeof parsed.version === 'string' && parsed.version ? parsed.version : null;
|
|
24
|
+
const name = typeof parsed.name === 'string' && parsed.name ? parsed.name : FALLBACK_PKG_NAME;
|
|
25
|
+
if (version) return { version, name };
|
|
26
|
+
} catch {
|
|
27
|
+
// try the next candidate
|
|
28
|
+
}
|
|
19
29
|
}
|
|
30
|
+
return { version: null, name: FALLBACK_PKG_NAME };
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
interface CachedResult {
|
|
@@ -38,15 +49,20 @@ export async function GET(request: Request): Promise<NextResponse> {
|
|
|
38
49
|
const check = url.searchParams.get('check') === '1';
|
|
39
50
|
|
|
40
51
|
const pkg = getPackageInfo();
|
|
41
|
-
const installed = pkg.version
|
|
52
|
+
const installed = pkg.version;
|
|
42
53
|
let latest: string | null = null;
|
|
43
54
|
let updateAvailable = false;
|
|
44
55
|
|
|
45
|
-
|
|
56
|
+
// Nothing to compare when the installed version is unknown: answer that, rather than
|
|
57
|
+
// offering an update against a placeholder.
|
|
58
|
+
if (check && installed) {
|
|
46
59
|
const now = Date.now();
|
|
47
60
|
if (cache && now - cache.checkedAt < CACHE_TTL_MS) {
|
|
48
61
|
latest = cache.latest;
|
|
49
|
-
|
|
62
|
+
// Recomputed against the version reading us now: `installed` is not cached, and
|
|
63
|
+
// reusing the cached flag made the banner offer an update that had just been
|
|
64
|
+
// installed, for up to the cache's fifteen minutes (seen right after an update).
|
|
65
|
+
updateAvailable = !!latest && latest !== installed;
|
|
50
66
|
} else {
|
|
51
67
|
try {
|
|
52
68
|
const pkgName = pkg.name || FALLBACK_PKG_NAME;
|
|
@@ -146,7 +146,7 @@ export default function SessionDrawer({ sessionId, onClose }: SessionDrawerProps
|
|
|
146
146
|
<button
|
|
147
147
|
type="button"
|
|
148
148
|
onClick={onClose}
|
|
149
|
-
aria-label="
|
|
149
|
+
aria-label="Close"
|
|
150
150
|
style={{
|
|
151
151
|
position: 'absolute',
|
|
152
152
|
top: isMobile ? 16 : 14,
|
|
@@ -185,9 +185,9 @@ export default function SessionDrawer({ sessionId, onClose }: SessionDrawerProps
|
|
|
185
185
|
{session.status ?? 'idle'}
|
|
186
186
|
</Row>
|
|
187
187
|
<Row label="TIPO">
|
|
188
|
-
{session.session_id.includes(':cron:') ? 'cron' : '
|
|
188
|
+
{session.session_id.includes(':cron:') ? 'cron' : 'session'}
|
|
189
189
|
</Row>
|
|
190
|
-
<Row label="
|
|
190
|
+
<Row label="MODEL">{session.model ?? '-'}</Row>
|
|
191
191
|
<Row label="COSTO">{formatUsdOrDash(session.cost_usd)}</Row>
|
|
192
192
|
<Row label="TOKEN">
|
|
193
193
|
in: {formatTokens(session.tokens_in)} out: {formatTokens(session.tokens_out)}
|
|
@@ -215,7 +215,7 @@ export default function SessionDrawer({ sessionId, onClose }: SessionDrawerProps
|
|
|
215
215
|
))}
|
|
216
216
|
</Section>
|
|
217
217
|
|
|
218
|
-
<Section title="
|
|
218
|
+
<Section title="CHILD SESSIONS">
|
|
219
219
|
{children.length === 0 && <Muted>No child sessions</Muted>}
|
|
220
220
|
{children.map((child) => (
|
|
221
221
|
<div key={child.session_id} style={{ fontSize: 12 }}>
|
|
@@ -4,46 +4,92 @@
|
|
|
4
4
|
* "Update available" banner for Rev4a itself.
|
|
5
5
|
*
|
|
6
6
|
* The update is spawned by `POST /api/update-check` and restarts the server, so this
|
|
7
|
-
* component cannot be told the outcome by the response. It records what it asked for
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* component cannot be told the outcome by the response. It records what it asked for and
|
|
8
|
+
* follows the restart from the browser.
|
|
9
|
+
*
|
|
10
|
+
* Three rules learned from the 1.6.0 update (2026-09-23):
|
|
11
|
+
* - a **success** is only a version that is *readable* and different. During the update
|
|
12
|
+
* npm rewrites `package.json`, and a read that failed used to answer `0.0.0`, which read
|
|
13
|
+
* as "the version moved" — the banner said "Updated to v0.0.0" and reloaded mid-install.
|
|
14
|
+
* - while the restart is in flight the banner shows an **updating** state, never the
|
|
15
|
+
* "Update now" button again: nobody should think the update failed and start it twice.
|
|
16
|
+
* A page loaded in that window resumes the wait by itself and reloads when the new
|
|
17
|
+
* version answers.
|
|
18
|
+
* - the banner never reloads **blindly**: if the version has not moved by the deadline it
|
|
19
|
+
* says the update has not finished (it may still be running, or it failed), points at
|
|
20
|
+
* `update.log` and offers Retry and Reload.
|
|
11
21
|
*/
|
|
12
|
-
import { useEffect, useState } from 'react';
|
|
22
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
23
|
+
import { Button } from '../components/ui';
|
|
13
24
|
|
|
14
25
|
interface VersionInfo {
|
|
15
|
-
installed: string;
|
|
26
|
+
installed: string | null;
|
|
16
27
|
latest: string | null;
|
|
17
28
|
updateAvailable: boolean;
|
|
18
29
|
log?: string;
|
|
19
30
|
}
|
|
20
31
|
|
|
21
32
|
const DISMISS_KEY = 'rev4a-update-dismissed';
|
|
22
|
-
/** What we asked for and from which version, so
|
|
33
|
+
/** What we asked for and from which version, so a reload can resume the wait. */
|
|
23
34
|
const PENDING_KEY = 'rev4a-update-pending';
|
|
24
35
|
|
|
25
|
-
/** How long to
|
|
26
|
-
const WAIT_MS =
|
|
36
|
+
/** How long to wait for the restart before saying the update has not finished. */
|
|
37
|
+
const WAIT_MS = 150_000;
|
|
38
|
+
/** How long a recorded request stays believable across reloads. */
|
|
39
|
+
const PENDING_TTL_MS = 15 * 60 * 1000;
|
|
27
40
|
|
|
28
|
-
interface Pending { target: string; from: string }
|
|
41
|
+
interface Pending { target: string; from: string; at: number }
|
|
29
42
|
|
|
30
43
|
function readPending(): Pending | null {
|
|
31
44
|
try {
|
|
32
45
|
const raw = sessionStorage.getItem(PENDING_KEY);
|
|
33
46
|
if (!raw) return null;
|
|
34
47
|
const parsed = JSON.parse(raw) as Pending;
|
|
35
|
-
|
|
48
|
+
if (!parsed || typeof parsed.target !== 'string' || typeof parsed.from !== 'string') return null;
|
|
49
|
+
return parsed;
|
|
36
50
|
} catch {
|
|
37
51
|
return null;
|
|
38
52
|
}
|
|
39
53
|
}
|
|
40
54
|
|
|
55
|
+
/** A version we can believe: present and not a placeholder. */
|
|
56
|
+
function usableVersion(v: string | null | undefined): v is string {
|
|
57
|
+
return typeof v === 'string' && v.length > 0 && v !== '0.0.0';
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
export default function VersionBanner() {
|
|
42
61
|
const [info, setInfo] = useState<VersionInfo | null>(null);
|
|
43
62
|
const [dismissed, setDismissed] = useState(true);
|
|
44
63
|
const [updating, setUpdating] = useState(false);
|
|
45
64
|
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
46
65
|
const [updateOk, setUpdateOk] = useState<string | null>(null);
|
|
66
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Follow the restart until the server answers with a different, readable version, then
|
|
70
|
+
* reload. `from` is the version we started from, so a resumed wait knows what to expect.
|
|
71
|
+
*/
|
|
72
|
+
const followRestart = useCallback((from: string) => {
|
|
73
|
+
const deadline = Date.now() + WAIT_MS;
|
|
74
|
+
const poll = async () => {
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch('/api/update-check', { cache: 'no-store' });
|
|
77
|
+
const d = (await res.json()) as VersionInfo;
|
|
78
|
+
if (usableVersion(d.installed) && d.installed !== from) {
|
|
79
|
+
sessionStorage.removeItem(PENDING_KEY);
|
|
80
|
+
window.location.reload();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
} catch { /* the server is down for the restart: expected */ }
|
|
84
|
+
if (Date.now() > deadline) {
|
|
85
|
+
setUpdating(false);
|
|
86
|
+
setUpdateError('The update has not finished after a couple of minutes. It may still be running — see update.log, then reload.');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
timerRef.current = setTimeout(poll, 3000);
|
|
90
|
+
};
|
|
91
|
+
timerRef.current = setTimeout(poll, 3000);
|
|
92
|
+
}, []);
|
|
47
93
|
|
|
48
94
|
useEffect(() => {
|
|
49
95
|
let active = true;
|
|
@@ -52,11 +98,20 @@ export default function VersionBanner() {
|
|
|
52
98
|
.then((d: VersionInfo) => {
|
|
53
99
|
if (!active) return;
|
|
54
100
|
const pending = readPending();
|
|
55
|
-
// Back from an update: did
|
|
56
|
-
if (pending && d.installed !== pending.from) {
|
|
101
|
+
// Back from an update: did a *readable* version actually replace the old one?
|
|
102
|
+
if (pending && usableVersion(d.installed) && d.installed !== pending.from) {
|
|
57
103
|
sessionStorage.removeItem(PENDING_KEY);
|
|
58
104
|
setUpdateOk(`Updated to v${d.installed}`);
|
|
59
|
-
setTimeout(() => { if (active) window.location.reload(); },
|
|
105
|
+
setTimeout(() => { if (active) window.location.reload(); }, 2500);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (pending && Date.now() - (pending.at ?? 0) < PENDING_TTL_MS) {
|
|
109
|
+
// The restart is probably in flight (a page loaded inside that window, or a
|
|
110
|
+
// reload before the new version answered): resume the wait, show no action.
|
|
111
|
+
setInfo(d);
|
|
112
|
+
setDismissed(false);
|
|
113
|
+
setUpdating(true);
|
|
114
|
+
followRestart(pending.from);
|
|
60
115
|
return;
|
|
61
116
|
}
|
|
62
117
|
if (pending) {
|
|
@@ -64,30 +119,30 @@ export default function VersionBanner() {
|
|
|
64
119
|
setInfo(d);
|
|
65
120
|
setDismissed(false);
|
|
66
121
|
setUpdateError(
|
|
67
|
-
`The update to v${pending.target}
|
|
68
|
-
+ ` See ${d.log ?? 'update.log'} in the Rev4a data directory.`,
|
|
122
|
+
`The update to v${pending.target} has not finished: ${usableVersion(d.installed) ? `Rev4a is still on v${d.installed}` : 'Rev4a is not answering with a version'}.`
|
|
123
|
+
+ ` See ${d.log ?? 'update.log'} in the Rev4a data directory, then reload.`,
|
|
69
124
|
);
|
|
70
125
|
return;
|
|
71
126
|
}
|
|
72
|
-
if (d.updateAvailable) {
|
|
127
|
+
if (d.updateAvailable && usableVersion(d.latest)) {
|
|
73
128
|
setInfo(d);
|
|
74
|
-
|
|
75
|
-
setDismissed(saved === d.latest);
|
|
129
|
+
setDismissed(localStorage.getItem(DISMISS_KEY) === d.latest);
|
|
76
130
|
}
|
|
77
131
|
})
|
|
78
132
|
.catch(() => {});
|
|
79
133
|
return () => { active = false; };
|
|
80
|
-
}, []);
|
|
134
|
+
}, [followRestart]);
|
|
135
|
+
|
|
136
|
+
// Clear a timer the component no longer owns (unmount or a full reload).
|
|
137
|
+
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
|
81
138
|
|
|
82
139
|
function handleDismiss() {
|
|
83
|
-
if (info?.latest)
|
|
84
|
-
localStorage.setItem(DISMISS_KEY, info.latest);
|
|
85
|
-
}
|
|
140
|
+
if (info?.latest) localStorage.setItem(DISMISS_KEY, info.latest);
|
|
86
141
|
setDismissed(true);
|
|
87
142
|
}
|
|
88
143
|
|
|
89
144
|
async function handleUpdate() {
|
|
90
|
-
if (!info) return;
|
|
145
|
+
if (!info || !usableVersion(info.installed)) return;
|
|
91
146
|
setUpdating(true);
|
|
92
147
|
setUpdateError(null);
|
|
93
148
|
setUpdateOk(null);
|
|
@@ -103,31 +158,16 @@ export default function VersionBanner() {
|
|
|
103
158
|
setUpdating(false);
|
|
104
159
|
return;
|
|
105
160
|
}
|
|
106
|
-
sessionStorage.setItem(PENDING_KEY, JSON.stringify({ target: info.latest ?? '', from: info.installed }));
|
|
161
|
+
sessionStorage.setItem(PENDING_KEY, JSON.stringify({ target: info.latest ?? '', from: info.installed, at: Date.now() }));
|
|
107
162
|
localStorage.removeItem(DISMISS_KEY);
|
|
108
|
-
|
|
109
|
-
// The server goes down and comes back on the new version: wait for its answer,
|
|
110
|
-
// then reload. A timeout reloads too — the next mount reports the outcome.
|
|
111
|
-
const deadline = Date.now() + WAIT_MS;
|
|
112
|
-
const poll = async () => {
|
|
113
|
-
if (Date.now() > deadline) { window.location.reload(); return; }
|
|
114
|
-
try {
|
|
115
|
-
const r = await fetch('/api/update-check', { cache: 'no-store' });
|
|
116
|
-
const d = (await r.json()) as VersionInfo;
|
|
117
|
-
if (d.installed !== info.installed) { window.location.reload(); return; }
|
|
118
|
-
} catch { /* the server is down for the update: expected */ }
|
|
119
|
-
setTimeout(poll, 3000);
|
|
120
|
-
};
|
|
121
|
-
setTimeout(poll, 3000);
|
|
163
|
+
followRestart(info.installed);
|
|
122
164
|
} catch (err) {
|
|
123
165
|
setUpdateError(err instanceof Error ? err.message : 'Network error');
|
|
124
166
|
setUpdating(false);
|
|
125
167
|
}
|
|
126
168
|
}
|
|
127
169
|
|
|
128
|
-
|
|
129
|
-
// succeeded); the "available" state needs the version info and no dismissal.
|
|
130
|
-
const show = Boolean(updateOk) || Boolean(updateError) || Boolean(info?.updateAvailable && !dismissed);
|
|
170
|
+
const show = Boolean(updateOk) || Boolean(updateError) || Boolean(updating) || Boolean(info?.updateAvailable && !dismissed);
|
|
131
171
|
if (!show) return null;
|
|
132
172
|
|
|
133
173
|
return (
|
|
@@ -138,30 +178,35 @@ export default function VersionBanner() {
|
|
|
138
178
|
<span className="update-banner__ok">{updateOk}</span>
|
|
139
179
|
) : updateError ? (
|
|
140
180
|
<span className="update-banner__err">{updateError}</span>
|
|
141
|
-
) :
|
|
181
|
+
) : updating ? (
|
|
182
|
+
<span className="update-banner__ok">Update started. Rev4a is restarting — this page reloads on its own when the new version answers.</span>
|
|
183
|
+
) : (
|
|
142
184
|
<>
|
|
143
185
|
<span className="update-banner__label">Update available</span>
|
|
144
186
|
<span className="update-banner__sep">—</span>
|
|
145
|
-
<span className="update-banner__version">v{info
|
|
146
|
-
<span className="update-banner__sep">(installed: v{info
|
|
187
|
+
<span className="update-banner__version">v{info?.latest}</span>
|
|
188
|
+
<span className="update-banner__sep">(installed: v{info?.installed})</span>
|
|
147
189
|
</>
|
|
148
|
-
)
|
|
190
|
+
)}
|
|
149
191
|
</span>
|
|
150
192
|
|
|
151
193
|
{!updateOk && (
|
|
152
194
|
<div className="update-banner__actions">
|
|
153
|
-
{
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
195
|
+
{updating ? (
|
|
196
|
+
// No action while the restart runs: the only honest one is to wait.
|
|
197
|
+
<Button variant="ghost" size="sm" onClick={() => window.location.reload()}>Reload</Button>
|
|
198
|
+
) : updateError ? (
|
|
199
|
+
<>
|
|
200
|
+
<Button variant="primary" size="sm" onClick={handleUpdate}>Retry</Button>
|
|
201
|
+
<Button variant="secondary" size="sm" onClick={() => window.location.reload()}>Reload</Button>
|
|
202
|
+
<Button variant="ghost" size="sm" onClick={handleDismiss}>Dismiss</Button>
|
|
203
|
+
</>
|
|
157
204
|
) : (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
205
|
+
<>
|
|
206
|
+
<Button variant="primary" size="sm" onClick={handleUpdate}>Update now</Button>
|
|
207
|
+
<Button variant="ghost" size="sm" onClick={handleDismiss}>Dismiss</Button>
|
|
208
|
+
</>
|
|
161
209
|
)}
|
|
162
|
-
<button className="ui-btn ui-btn--ghost ui-btn--sm" onClick={handleDismiss}>
|
|
163
|
-
Dismiss
|
|
164
|
-
</button>
|
|
165
210
|
</div>
|
|
166
211
|
)}
|
|
167
212
|
</div>
|
|
@@ -9,11 +9,16 @@ interface MetricProps {
|
|
|
9
9
|
subtitle?: ReactNode;
|
|
10
10
|
tone?: Tone;
|
|
11
11
|
loading?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* `sm` is the compact form for grids inside a dialog or a dense panel: same anatomy,
|
|
14
|
+
* smaller value and tighter spacing, so four of them fit on one line.
|
|
15
|
+
*/
|
|
16
|
+
size?: 'md' | 'sm';
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
export function Metric({ title, value, subtitle, tone = 'neutral', loading = false }: MetricProps) {
|
|
19
|
+
export function Metric({ title, value, subtitle, tone = 'neutral', loading = false, size = 'md' }: MetricProps) {
|
|
15
20
|
return (
|
|
16
|
-
<Surface as="article" tone={tone}>
|
|
21
|
+
<Surface as="article" tone={tone} className={size === 'sm' ? 'ui-metric--sm' : undefined}>
|
|
17
22
|
<div className="ui-kicker">{title}</div>
|
|
18
23
|
<div className="ui-metric-value">{loading ? <SkeletonMetric /> : value}</div>
|
|
19
24
|
{subtitle !== undefined && <div className="ui-muted ui-metric-subtitle">{loading ? <SkeletonLines count={1} /> : subtitle}</div>}
|