Show WebUI version and update action in the Web Applications list
This commit is contained in:
@@ -28,6 +28,7 @@ import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
|
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
|
||||||
|
import { WebAppVersionCell } from '@/features/webapps/WebAppVersionCell';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -354,6 +355,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
return { obj, schema: schem, list };
|
return { obj, schema: schem, list };
|
||||||
}, [schema, viewName]);
|
}, [schema, viewName]);
|
||||||
|
|
||||||
|
// The Web Applications list gets an extra client-side column showing the
|
||||||
|
// installed version of the WebUI app (and an update action when a newer
|
||||||
|
// release exists), which the server does not expose as an object property.
|
||||||
|
const isAppList = viewName === 'x:Application';
|
||||||
|
const displayColumns = useMemo(() => {
|
||||||
|
const columns = resolved?.list?.columns ?? [];
|
||||||
|
if (!isAppList) return columns;
|
||||||
|
return [...columns, { name: 'x:webuiVersion', label: t('webApps.version', 'Version') }];
|
||||||
|
}, [resolved?.list?.columns, isAppList, t]);
|
||||||
|
|
||||||
const [items, setItems] = useState<Record<string, unknown>[]>([]);
|
const [items, setItems] = useState<Record<string, unknown>[]>([]);
|
||||||
const [total, setTotal] = useState<number | null>(null);
|
const [total, setTotal] = useState<number | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -420,7 +431,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const accountId = getAccountId(obj.objectName);
|
const accountId = getAccountId(obj.objectName);
|
||||||
const properties = ['id', ...list.columns.map((c) => c.name)];
|
const properties = ['id', ...list.columns.map((c) => c.name), ...(isAppList ? ['resourceUrl'] : [])];
|
||||||
const filter = buildFilter();
|
const filter = buildFilter();
|
||||||
const sortArr = buildSort();
|
const sortArr = buildSort();
|
||||||
|
|
||||||
@@ -470,7 +481,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[resolved, schema, buildFilter, buildSort, t],
|
[resolved, schema, buildFilter, buildSort, isAppList, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1228,7 +1239,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
{list.columns.map((col) => (
|
{displayColumns.map((col) => (
|
||||||
<th key={col.name} className="px-3 py-3 text-left font-medium text-muted-foreground">
|
<th key={col.name} className="px-3 py-3 text-left font-medium text-muted-foreground">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{col.label}
|
{col.label}
|
||||||
@@ -1247,7 +1258,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
{loading && items.length === 0 ? (
|
{loading && items.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
colSpan={displayColumns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
||||||
className="px-3 py-12 text-center"
|
className="px-3 py-12 text-center"
|
||||||
>
|
>
|
||||||
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||||
@@ -1256,7 +1267,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={list.columns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
colSpan={displayColumns.length + (hasMassActions ? 1 : 0) + (hasItemActions ? 1 : 0)}
|
||||||
className="px-3 py-12 text-center text-muted-foreground"
|
className="px-3 py-12 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t('list.noResults', 'No results found')}
|
{t('list.noResults', 'No results found')}
|
||||||
@@ -1280,15 +1291,19 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
{list.columns.map((col) => (
|
{displayColumns.map((col) => (
|
||||||
<td key={col.name} className="px-3 py-2">
|
<td key={col.name} className="px-3 py-2">
|
||||||
{renderCellValue(
|
{col.name === 'x:webuiVersion' ? (
|
||||||
item[col.name],
|
<WebAppVersionCell resourceUrl={item.resourceUrl} />
|
||||||
fields[col.name],
|
) : (
|
||||||
col.name,
|
renderCellValue(
|
||||||
schema!,
|
item[col.name],
|
||||||
resolved.obj.objectName,
|
fields[col.name],
|
||||||
getDisplayName,
|
col.name,
|
||||||
|
schema!,
|
||||||
|
resolved.obj.objectName,
|
||||||
|
getDisplayName,
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ArrowUpCircle, Loader2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { jmapSet, getAccountId } from '@/services/jmap/client';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { isUpdateAvailable, normalizeVersion } from '@/lib/versionCompare';
|
||||||
|
import type { JmapSetResponse } from '@/types/jmap';
|
||||||
|
|
||||||
|
const WEBUI_REPO = 'stalwartlabs/webui';
|
||||||
|
const GITHUB_LATEST_RE = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/releases\/latest\//;
|
||||||
|
const LATEST_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// The GitHub API is rate-limited per IP, so latest-release lookups are shared
|
||||||
|
// and deduplicated across rows and remounts.
|
||||||
|
const latestReleaseCache = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
function fetchLatestTag(repo: string): Promise<string | null> {
|
||||||
|
const cached = latestReleaseCache.get(repo);
|
||||||
|
if (cached) return cached;
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
|
||||||
|
headers: { Accept: 'application/vnd.github+json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = (await response.json()) as { tag_name?: unknown };
|
||||||
|
return typeof data.tag_name === 'string' ? data.tag_name : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => latestReleaseCache.delete(repo), LATEST_CACHE_TTL_MS);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
latestReleaseCache.set(repo, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebAppVersionCellProps {
|
||||||
|
resourceUrl: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebAppVersionCell({ resourceUrl }: WebAppVersionCellProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const repo = typeof resourceUrl === 'string' ? GITHUB_LATEST_RE.exec(resourceUrl)?.[1] : undefined;
|
||||||
|
// The running bundle's version is only meaningful for the WebUI application
|
||||||
|
// itself; other apps do not expose their installed version.
|
||||||
|
const currentVersion = repo === WEBUI_REPO ? __APP_VERSION__ : null;
|
||||||
|
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!repo) return;
|
||||||
|
let cancelled = false;
|
||||||
|
fetchLatestTag(repo).then((tag) => {
|
||||||
|
if (!cancelled && tag) setLatestVersion(tag);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [repo]);
|
||||||
|
|
||||||
|
if (!repo || !currentVersion) {
|
||||||
|
return <span className="text-muted-foreground">—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAvailable = latestVersion !== null && isUpdateAvailable(latestVersion, currentVersion);
|
||||||
|
|
||||||
|
async function handleUpdate() {
|
||||||
|
setUpdating(true);
|
||||||
|
try {
|
||||||
|
const accountId = getAccountId('x:Action');
|
||||||
|
const responses = await jmapSet('x:Action', accountId, {
|
||||||
|
create: { 'action-0': { '@type': 'UpdateApps' } },
|
||||||
|
});
|
||||||
|
const result = responses[responses.length - 1][1] as unknown as JmapSetResponse;
|
||||||
|
if (result.created?.['action-0']) {
|
||||||
|
toast({
|
||||||
|
title: t('webApps.updateStarted', 'Update started'),
|
||||||
|
description: t(
|
||||||
|
'webApps.updateStartedBody',
|
||||||
|
'The server is downloading the latest version. Reload the page in a few seconds to use it.',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const err = result.notCreated?.['action-0'];
|
||||||
|
throw new Error(err?.description ?? err?.type ?? t('webApps.updateRejected', 'The server rejected the update.'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('webApps.updateFailed', 'Update failed'),
|
||||||
|
description: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<span>v{normalizeVersion(currentVersion)}</span>
|
||||||
|
{latestVersion && !updateAvailable && (
|
||||||
|
<span className="text-xs text-muted-foreground">{t('webApps.upToDate', 'Up to date')}</span>
|
||||||
|
)}
|
||||||
|
{updateAvailable && latestVersion && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1"
|
||||||
|
disabled={updating}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
{updating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ArrowUpCircle className="h-3.5 w-3.5" />}
|
||||||
|
{t('webApps.updateTo', 'Update to v{{version}}', { version: normalizeVersion(latestVersion) })}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('webApps.confirmTitle', 'Update application?')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t(
|
||||||
|
'webApps.confirmBody',
|
||||||
|
'The application will be updated from v{{from}} to v{{to}}. The server downloads the new package and starts serving it immediately.',
|
||||||
|
{ from: normalizeVersion(currentVersion), to: normalizeVersion(latestVersion) },
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleUpdate}>{t('webApps.confirm', 'Update')}</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -371,5 +371,17 @@
|
|||||||
"failedToLoad": "Failed to load",
|
"failedToLoad": "Failed to load",
|
||||||
"noGetResponse": "No get response",
|
"noGetResponse": "No get response",
|
||||||
"objectNotFound": "Object not found"
|
"objectNotFound": "Object not found"
|
||||||
|
},
|
||||||
|
"webApps": {
|
||||||
|
"confirm": "Update",
|
||||||
|
"confirmBody": "The application will be updated from v{{from}} to v{{to}}. The server downloads the new package and starts serving it immediately.",
|
||||||
|
"confirmTitle": "Update application?",
|
||||||
|
"updateFailed": "Update failed",
|
||||||
|
"updateRejected": "The server rejected the update.",
|
||||||
|
"updateStarted": "Update started",
|
||||||
|
"updateStartedBody": "The server is downloading the latest version. Reload the page in a few seconds to use it.",
|
||||||
|
"updateTo": "Update to v{{version}}",
|
||||||
|
"upToDate": "Up to date",
|
||||||
|
"version": "Version"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { compareVersions, isUpdateAvailable, normalizeVersion } from './versionCompare';
|
||||||
|
|
||||||
|
describe('normalizeVersion', () => {
|
||||||
|
it('strips a leading v prefix and whitespace', () => {
|
||||||
|
expect(normalizeVersion('v1.2.3')).toBe('1.2.3');
|
||||||
|
expect(normalizeVersion('V2.0')).toBe('2.0');
|
||||||
|
expect(normalizeVersion(' 1.0.6 ')).toBe('1.0.6');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compareVersions', () => {
|
||||||
|
it('orders dotted numeric versions', () => {
|
||||||
|
expect(compareVersions('v1.0.7', '1.0.6')).toBeGreaterThan(0);
|
||||||
|
expect(compareVersions('1.0.6', 'v1.0.7')).toBeLessThan(0);
|
||||||
|
expect(compareVersions('1.2.0', '1.2')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unparsable versions', () => {
|
||||||
|
expect(compareVersions('nightly', '1.0.6')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isUpdateAvailable', () => {
|
||||||
|
it('is false when latest equals current', () => {
|
||||||
|
expect(isUpdateAvailable('v1.0.6', '1.0.6')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true when latest is newer', () => {
|
||||||
|
expect(isUpdateAvailable('v1.1.0', '1.0.6')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when latest is older', () => {
|
||||||
|
expect(isUpdateAvailable('v1.0.5', '1.0.6')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to string difference for unparsable tags', () => {
|
||||||
|
expect(isUpdateAvailable('nightly-2', '1.0.6')).toBe(true);
|
||||||
|
expect(isUpdateAvailable('v1.0.6', '1.0.6-beta')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function normalizeVersion(version: string): string {
|
||||||
|
return version.trim().replace(/^v/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compares two dotted numeric versions; returns null when either is unparsable.
|
||||||
|
export function compareVersions(a: string, b: string): number | null {
|
||||||
|
const pa = normalizeVersion(a).split('.').map(Number);
|
||||||
|
const pb = normalizeVersion(b).split('.').map(Number);
|
||||||
|
if (pa.some(Number.isNaN) || pb.some(Number.isNaN)) return null;
|
||||||
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||||
|
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls back to a string difference when the versions are not dotted numerics,
|
||||||
|
// so an unexpected tag format still surfaces as an available update.
|
||||||
|
export function isUpdateAvailable(latestVersion: string, currentVersion: string): boolean {
|
||||||
|
const cmp = compareVersions(latestVersion, currentVersion);
|
||||||
|
if (cmp === null) return normalizeVersion(latestVersion) !== normalizeVersion(currentVersion);
|
||||||
|
return cmp > 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user