Remove the Web Applications version column and update action

This commit is contained in:
Steven RYDELL
2026-07-30 12:00:01 +02:00
parent 5dac3490ee
commit 40c04555b4
6 changed files with 9 additions and 261 deletions
+3
View File
@@ -19,6 +19,9 @@ All notable changes to this project will be documented in this file. This projec
### Fixed ### Fixed
- Section URLs without a view (e.g. /admin) redirect to the first accessible page instead of the "Select a view" empty state. - Section URLs without a view (e.g. /admin) redirect to the first accessible page instead of the "Select a view" empty state.
### Removed
- The Web Applications list no longer shows a "Version" column or an "Update" button, because Stalwart does not expose the installed version of each web application and `/latest/` GitHub URLs hide it.
## [1.0.7] - 2026-07-29 ## [1.0.7] - 2026-07-29
### Added ### Added
+6 -15
View File
@@ -28,7 +28,6 @@ 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,
@@ -355,15 +354,10 @@ 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 displayColumns = useMemo(() => {
const columns = resolved?.list?.columns ?? []; const columns = resolved?.list?.columns ?? [];
if (!isAppList) return columns; return columns;
return [...columns, { name: 'x:webuiVersion', label: t('webApps.version', 'Version') }]; }, [resolved?.list?.columns]);
}, [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);
@@ -431,7 +425,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), ...(isAppList ? ['resourceUrl'] : [])]; const properties = ['id', ...list.columns.map((c) => c.name)];
const filter = buildFilter(); const filter = buildFilter();
const sortArr = buildSort(); const sortArr = buildSort();
@@ -481,7 +475,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
setLoading(false); setLoading(false);
} }
}, },
[resolved, schema, buildFilter, buildSort, isAppList, t], [resolved, schema, buildFilter, buildSort],
); );
useEffect(() => { useEffect(() => {
@@ -1296,10 +1290,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
)} )}
{displayColumns.map((col) => ( {displayColumns.map((col) => (
<td key={col.name} className="px-3 py-2"> <td key={col.name} className="px-3 py-2">
{col.name === 'x:webuiVersion' ? ( {renderCellValue(
<WebAppVersionCell resourceUrl={item.resourceUrl} />
) : (
renderCellValue(
item[col.name], item[col.name],
fields[col.name], fields[col.name],
col.name, col.name,
@@ -1307,7 +1298,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
resolved.obj.objectName, resolved.obj.objectName,
getDisplayName, getDisplayName,
) )
)} }
</td> </td>
))} ))}
{hasItemActions && <td className="px-3 py-2 text-right">{renderItemActions(item)}</td>} {hasItemActions && <td className="px-3 py-2 text-right">{renderItemActions(item)}</td>}
-158
View File
@@ -1,158 +0,0 @@
/*
* 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>
);
}
-12
View File
@@ -397,17 +397,5 @@
"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"
} }
} }
-47
View File
@@ -1,47 +0,0 @@
/*
* 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);
});
});
-29
View File
@@ -1,29 +0,0 @@
/*
* 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;
}