feat: beta channels for core and plugins with one stable/beta release workflow

This commit is contained in:
LukeGus committed 2026-10-07 16:25:58 -05:00
1 parent 6a855c0fa8
commit f73c4b5ad6
55 files changed
+16200 -460

No files matched your search

+22 -2
View File
@@ -247,6 +247,7 @@ export interface PluginSummary {
registryId?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
channel?: PluginChannel;
description?: string;
author?: string;
repository?: string;
@@ -269,8 +270,12 @@ function announcePluginsChanged(): void {
window.dispatchEvent(new CustomEvent(PLUGINS_CHANGED_EVENT));
}
export type PluginChannel = "stable" | "beta";
export interface RegistryPluginVersion {
version: string;
/** A beta, offered only to plugins on the beta channel. */
prerelease?: boolean;
compatible: boolean;
capabilities: string[];
publishedAt?: string;
@@ -290,7 +295,11 @@ export interface RegistryPluginEntry {
videoId?: string;
features?: string[];
versions: RegistryPluginVersion[];
/** Newest release on the plugin's channel. */
latestVersion: string | null;
/** Newest beta, when one is newer than stable. */
latestBeta?: string | null;
channel?: PluginChannel;
installed: boolean;
installedVersion: string | null;
updateAvailable: boolean;
@@ -443,8 +452,12 @@ export async function retryPlugin(pluginId: string): Promise<void> {
export async function setPluginOptions(
pluginId: string,
options: { autoUpdate?: boolean; pinned?: boolean },
): Promise<{ autoUpdate: boolean; pinnedVersion: string | null }> {
options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel },
): Promise<{
autoUpdate: boolean;
pinnedVersion: string | null;
channel: PluginChannel;
}> {
const response = await rbacApi.patch(
`${pluginPath(pluginId)}/options`,
options,
@@ -452,6 +465,13 @@ export async function setPluginOptions(
return response.data;
}
export async function setAllPluginChannels(
channel: PluginChannel,
): Promise<{ changed: string[] }> {
const response = await rbacApi.post("/plugins/channel", { channel });
return response.data;
}
export async function getPluginData(
pluginId: string,
): Promise<PluginDataSummary> {
+41
View File
@@ -42,6 +42,7 @@ export async function getReleasesRSS(
export interface VersionInfo {
status?: "up_to_date" | "requires_update" | "beta" | "unknown";
channel?: "stable" | "beta";
/** Same value as remoteVersion; the endpoint sends both. */
version?: string;
localVersion?: string;
@@ -81,6 +82,46 @@ export async function getVersionInfo(checkRemote = true): Promise<VersionInfo> {
}
}
export type UpdateChannel = "stable" | "beta";
export interface UpdateChannelState {
channel: UpdateChannel;
/** The admin's choice; a beta build reports beta whatever it is. */
stored: UpdateChannel;
runningBeta: boolean;
}
export async function getUpdateChannel(): Promise<UpdateChannelState> {
const response = await authApi.get("/version/channel");
return response.data;
}
export async function setUpdateChannel(
channel: UpdateChannel,
): Promise<UpdateChannelState> {
const response = await authApi.put("/version/channel", { channel });
return response.data;
}
export interface ReleaseInfo {
version: string;
tagName: string;
name: string;
url: string;
publishedAt: string;
prerelease: boolean;
notes: string;
}
export async function getChannelReleases(): Promise<{
localVersion: string | null;
stable: ReleaseInfo | null;
beta: ReleaseInfo | null;
}> {
const response = await authApi.get("/version/releases");
return response.data;
}
// ============================================================================
// DATABASE HEALTH
// ============================================================================
+19 -5
View File
@@ -87,15 +87,29 @@ export function describeEnvironment(
}
/** A bug report on the Termix core repo. */
export function reportCoreIssueUrl(
termixVersion?: string,
template: "bug_report.yml" | "beta_feedback.yml" = "bug_report.yml",
): string {
export function reportCoreIssueUrl(termixVersion?: string): string {
return buildIssueUrl(CORE_REPO_URL, {
template,
template: "bug_report.yml",
fields: {
"termix-version": termixVersion,
environment: describeEnvironment(),
},
});
}
/** Beta feedback on the core repo, listing every plugin running a beta. */
export function reportBetaFeedbackUrl(
termixVersion?: string,
betaPlugins: Array<{ id: string; version: string }> = [],
): string {
return buildIssueUrl(CORE_REPO_URL, {
template: "beta_feedback.yml",
fields: {
"termix-version": termixVersion,
environment: describeEnvironment(),
"beta-plugins": betaPlugins
.map((plugin) => `${plugin.id} ${plugin.version}`)
.join("\n"),
},
});
}
+56 -7
View File
@@ -1688,10 +1688,6 @@
"twoFaOn": "On",
"twoFaOff": "Off",
"versionLabel": "Version",
"betaProgramTitle": "Beta Program",
"betaProgramDescription": "Try upcoming features early with the weekly rolling :beta Docker tag. Unstable, not for production.",
"betaProgramFeedback": "Found a bug? Report it here.",
"betaProgramLearnMore": "Learn More",
"cliTitle": "Termix CLI",
"cliDescription": "Open terminals, run commands and move files from your own shell.",
"cliLearnMore": "Learn More",
@@ -1826,7 +1822,13 @@
"linkedProfileDescription": "This device uses your account on the server and keeps a copy of your data, so it still works offline.",
"manageSync": "Manage sync",
"setUpSync": "Link to a server",
"settingsTabsAndShortcuts": "Tabs and Shortcuts"
"settingsTabsAndShortcuts": "Tabs and Shortcuts",
"onBetaTitle": "You are on a beta",
"onBetaDescription": "Thanks for testing. Tell us what breaks.",
"tryBetaTitle": "Try new features early",
"tryBetaDescription": "Join the beta program in Updates.",
"sendBetaFeedback": "Send beta feedback",
"openUpdates": "Updates"
}
}
},
@@ -2168,7 +2170,22 @@
"reportIssue": "Report an issue",
"reportTo": "Report to {{author}}",
"requestFeature": "Request a feature",
"openSettings": "Open its settings"
"openSettings": "Open its settings",
"beta": {
"badge": "Beta",
"badgeHint": "This is a beta version. It can have bugs.",
"try": "Try beta {{version}}",
"confirmTitle": "Use betas for {{name}}?",
"confirmBody": "Betas get new features and fixes first, but they can have bugs. You can switch back to stable at any time.",
"confirmInstall": "Install {{version}}",
"confirmSwitch": "Use betas",
"leftWhileOnBeta": "{{name}} stays on {{version}} until a stable release is newer.",
"channel": "Beta versions",
"channelHint": "Get new versions early. They can have bugs.",
"waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.",
"versionLabel": "{{version}} (beta)",
"sendFeedback": "Send beta feedback"
}
}
},
"settings": {
@@ -2207,7 +2224,39 @@
"instanceSettings": "Server settings",
"nothingHere": "Nothing to set here.",
"searchPage": "Search this page",
"noPageMatches": "Nothing on this page matches."
"noPageMatches": "Nothing on this page matches.",
"updates": {
"title": "Updates",
"betaProgram": "Beta program",
"betaIntro": "Betas are early builds of the next Termix version. They come out on Tuesdays and Fridays when there is something new to try. They can have bugs, and your feedback is what makes the stable release better.",
"termixBetas": "Termix betas",
"termixBetasHint": "Tell me about new betas, not only stable releases.",
"runningBetaBuild": "You are running a beta build. To leave, switch back to a stable image or installer.",
"howToInstall": "How to get the beta",
"desktopInstall": "Download the beta installer and install it over this one.",
"downloadBeta": "Download {{version}}",
"dockerInstall": "Change your image to the beta tag and restart the container:",
"copy": "Copy",
"copied": "Copied",
"copyFailed": "Could not copy",
"learnMore": "Learn more about betas",
"feedback": "Feedback",
"feedbackHint": "Found a bug or have an idea? It goes straight to the developers with your version filled in.",
"sendFeedback": "Send beta feedback",
"releases": "Releases",
"thisVersion": "This version",
"newestStable": "Newest stable",
"newestBeta": "Newest beta",
"noRelease": "None right now",
"pluginBetas": "Plugin betas",
"pluginBetasIntro": "Each plugin can use betas on its own. To try one, open it and press Try beta.",
"openPlugins": "Open Plugins",
"waitingForStable": "Back on stable. Stays on {{version}} until a stable release is newer.",
"backToStable": "Back to stable",
"allPluginsBeta": "Use betas for all plugins",
"allPluginsStable": "All plugins back to stable",
"saveFailed": "Could not save the change"
}
},
"managerCard": {
"refresh": "Refresh",
+64 -1
View File
@@ -6,6 +6,7 @@ import {
Database,
Download,
ExternalLink,
FlaskConical,
Lightbulb,
Pin,
RotateCcw,
@@ -26,12 +27,15 @@ import {
} from "@/components/select";
import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
import {
BetaBadge,
CapabilityRow,
InstallCountFact,
PluginIconBox,
UnverifiedBadge,
} from "./plugin-bits";
import {
betaFeedbackUrl,
betaToTry,
describeContributions,
formatBytes,
orderByRisk,
@@ -80,6 +84,10 @@ export function PluginDetail({
import.meta.env.VITE_APP_VERSION || undefined,
);
const featureUrl = requestFeatureUrl(plugin);
const feedbackUrl = plugin.isBeta
? betaFeedbackUrl(plugin, import.meta.env.VITE_APP_VERSION || undefined)
: null;
const tryableBeta = betaToTry(plugin);
return (
<div
@@ -98,6 +106,7 @@ export function PluginDetail({
</span>
<Facts className="text-[11px] text-muted-foreground/70">
{plugin.unverified && <UnverifiedBadge />}
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>}
{plugin.version ? (
<span>v{plugin.version}</span>
@@ -135,6 +144,18 @@ export function PluginDetail({
})}
</Button>
)}
{tryableBeta && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={busy || locked}
onClick={() => void manager.tryBeta(plugin)}
>
<FlaskConical className="size-3.5" />
{t("plugins.manager.beta.try", { version: tryableBeta })}
</Button>
)}
{plugin.installed ? (
<>
{failed ? (
@@ -299,7 +320,11 @@ export function PluginDetail({
? t("plugins.manager.latestVersion", {
version: v.version,
})
: v.version}
: v.prerelease
? t("plugins.manager.beta.versionLabel", {
version: v.version,
})
: v.version}
</SelectItem>
))}
</SelectContent>
@@ -346,6 +371,30 @@ export function PluginDetail({
}
/>
</div>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium">
<FlaskConical className="size-3 text-muted-foreground" />
{t("plugins.manager.beta.channel")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{plugin.channel === "stable" && plugin.isBeta
? t("plugins.manager.beta.waitingForStable", {
version: plugin.version,
})
: t("plugins.manager.beta.channelHint")}
</span>
</div>
<FakeSwitch
checked={plugin.channel === "beta"}
disabled={busy || locked}
onChange={(v) =>
void (v
? manager.tryBeta(plugin)
: manager.leaveBeta(plugin))
}
/>
</div>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium">
@@ -401,6 +450,20 @@ export function PluginDetail({
</a>
)}
{feedbackUrl && (
<a
href={feedbackUrl}
target="_blank"
rel="noreferrer"
className={LINK_ROW}
>
<FlaskConical className="size-3.5 shrink-0" />
<span className="truncate">
{t("plugins.manager.beta.sendFeedback")}
</span>
</a>
)}
{featureUrl && (
<a
href={featureUrl}
+2
View File
@@ -35,6 +35,7 @@ import {
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
import { cn } from "@/lib/utils";
import {
BetaBadge,
InstallCountFact,
PluginIconBox,
UnverifiedBadge,
@@ -469,6 +470,7 @@ function InstalledCard({
facts={
<>
{plugin.unverified && <UnverifiedBadge />}
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>}
<span>v{plugin.version}</span>
{adds.length > 0 && <span>{adds.join(", ")}</span>}
+12
View File
@@ -88,6 +88,18 @@ export function UnverifiedBadge() {
);
}
export function BetaBadge() {
const { t } = useTranslation();
return (
<span
className="shrink-0 border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-accent-brand"
title={t("plugins.manager.beta.badgeHint")}
>
{t("plugins.manager.beta.badge")}
</span>
);
}
/** Active installs when the registry has them, release downloads otherwise. */
export function InstallCountFact({
count,
+56
View File
@@ -11,6 +11,7 @@ import {
type ChangelogRelease,
} from "@termix-ssh/plugin-sdk/changelog";
import type {
PluginChannel,
PluginSummary,
PluginUploadPreview,
RegistryPluginEntry,
@@ -38,6 +39,11 @@ export interface PluginEntry {
installed: boolean;
version: string | null;
latestVersion: string | null;
/** Newest beta, when one is newer than stable. */
latestBeta: string | null;
channel: PluginChannel;
/** The installed copy is a beta. */
isBeta: boolean;
updateAvailable: boolean;
addedCapabilities: string[];
versions: RegistryPluginVersion[];
@@ -60,6 +66,26 @@ export interface PluginEntry {
export type PluginSource = "bundled" | "official" | "unverified";
export function isPrerelease(version: string | null | undefined): boolean {
return Boolean(
version && semver.valid(version) && semver.prerelease(version),
);
}
/** A beta newer than what is installed, worth offering with "Try beta". */
export function betaToTry(
plugin: Pick<PluginEntry, "installed" | "version" | "latestBeta" | "channel">,
): string | null {
const { latestBeta, version } = plugin;
if (!plugin.installed || plugin.channel === "beta" || !latestBeta) {
return null;
}
if (!version || !semver.valid(version) || !semver.valid(latestBeta)) {
return null;
}
return semver.gt(latestBeta, version) ? latestBeta : null;
}
/** Where an installed copy came from, as the issue report names it. */
export function pluginSource(summary: PluginSummary): PluginSource {
if (summary.tier === "unverified") return "unverified";
@@ -101,6 +127,9 @@ export function mergePlugins(
installed: true,
version: summary.version,
latestVersion: entry?.latestVersion ?? null,
latestBeta: entry?.latestBeta ?? null,
channel: summary.channel ?? entry?.channel ?? "stable",
isBeta: isPrerelease(summary.version),
updateAvailable: entry?.updateAvailable ?? false,
addedCapabilities: entry?.addedCapabilities ?? [],
versions: entry?.versions ?? [],
@@ -138,6 +167,9 @@ export function mergePlugins(
installed: false,
version: null,
latestVersion: entry.latestVersion,
latestBeta: entry.latestBeta ?? null,
channel: "stable",
isBeta: false,
updateAvailable: false,
addedCapabilities: [],
versions: entry.versions,
@@ -173,6 +205,9 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
installed: preview.replaces !== null,
version: preview.replaces,
latestVersion: preview.version,
latestBeta: null,
channel: "stable",
isBeta: isPrerelease(preview.replaces),
updateAvailable: false,
addedCapabilities: [],
versions: [],
@@ -358,6 +393,27 @@ ${state}`,
});
}
/**
* Beta feedback on the plugin's own repo, through the org beta form. Null when
* the repository is not on GitHub.
*/
export function betaFeedbackUrl(
plugin: Pick<PluginEntry, "id" | "repository" | "version" | "lastError">,
termixVersion?: string,
): string | null {
const repo = normalizeGitHubRepo(plugin.repository);
if (!repo) return null;
return buildIssueUrl(repo, {
template: "beta_feedback.yml",
fields: {
"termix-version": termixVersion,
"plugin-version": plugin.version,
environment: describeEnvironment(),
logs: plugin.lastError,
},
});
}
/** A new feature request on the plugin's own repo, or null when not on GitHub. */
export function requestFeatureUrl(
plugin: Pick<PluginEntry, "repository">,
+59 -1
View File
@@ -12,6 +12,7 @@ import {
installUploadedPlugin,
previewPluginState,
retryPlugin,
setAllPluginChannels,
setPluginOptions,
setPluginState,
uninstallPlugin,
@@ -19,6 +20,7 @@ import {
updatePlugin,
uploadPlugin,
type DeveloperModeState,
type PluginChannel,
type PluginSummary,
type RegistryListing,
} from "@/api/plugins-api";
@@ -484,7 +486,11 @@ export function usePluginsManager() {
const setOptions = useCallback(
(
plugin: PluginEntry,
options: { autoUpdate?: boolean; pinned?: boolean },
options: {
autoUpdate?: boolean;
pinned?: boolean;
channel?: PluginChannel;
},
) =>
run(plugin.id, async () => {
try {
@@ -496,6 +502,55 @@ export function usePluginsManager() {
[run, failToast],
);
/** Moves a plugin to betas and installs the newest one. */
const tryBeta = useCallback(
async (plugin: PluginEntry) => {
const version = plugin.latestBeta;
const ok = await confirm({
title: t("plugins.manager.beta.confirmTitle", { name: plugin.name }),
description: t("plugins.manager.beta.confirmBody"),
confirmLabel: version
? t("plugins.manager.beta.confirmInstall", { version })
: t("plugins.manager.beta.confirmSwitch"),
destructive: false,
});
if (!ok) return;
await setOptions(plugin, { channel: "beta", pinned: false });
if (version && version !== plugin.version) {
await requestUpdate({ ...plugin, pinnedVersion: null }, version);
}
},
[confirm, t, setOptions, requestUpdate],
);
/** Back to stable. An installed beta stays until stable passes it. */
const leaveBeta = useCallback(
async (plugin: PluginEntry) => {
await setOptions(plugin, { channel: "stable" });
if (plugin.isBeta) {
toast.info(
t("plugins.manager.beta.leftWhileOnBeta", {
name: plugin.name,
version: plugin.version,
}),
);
}
},
[setOptions, t],
);
const setAllChannels = useCallback(
(channel: PluginChannel) =>
run("*", async () => {
try {
await setAllPluginChannels(channel);
} catch (error) {
failToast(error, "plugins.manager.errors.options", "");
}
}),
[run, failToast],
);
const updateAll = useCallback(
() =>
run("*", async () => {
@@ -545,6 +600,9 @@ export function usePluginsManager() {
uninstall,
removeData,
setOptions,
tryBeta,
leaveBeta,
setAllChannels,
updateAll,
};
}
+2
View File
@@ -29,6 +29,7 @@ import {
useFeatureSettings,
} from "./FeatureSettingsSections";
import { KeybindingsSettings } from "./KeybindingsSettings";
import { UpdatesSettings } from "./UpdatesSettings";
import { SettingsEmbedContext } from "./settings-embed";
import { useSettingsPageFilter } from "./page-filter";
import {
@@ -376,6 +377,7 @@ export function SettingsTab({
)}
</SettingsEmbedContext.Provider>
{page.id === "keybindings" && <KeybindingsSettings />}
{page.id === "updates" && isAdmin && <UpdatesSettings />}
{pluginFeature && (
<>
{pluginFeature.user && (
+320
View File
@@ -0,0 +1,320 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Copy, ExternalLink, FlaskConical, Puzzle, Tag } from "lucide-react";
import { Button } from "@/components/button";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import {
getChannelReleases,
getUpdateChannel,
setUpdateChannel,
type ReleaseInfo,
type UpdateChannelState,
} from "@/api/system-status-api";
import {
getPlugins,
setAllPluginChannels,
setPluginOptions,
type PluginSummary,
} from "@/api/plugins-api";
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
const BETA_DOCS_URL =
"https://docs.termix.site/install/server/docker#beta-builds";
function onBeta(plugin: PluginSummary): boolean {
return plugin.channel === "beta" || isPrerelease(plugin.version);
}
function ReleaseRow({
label,
release,
}: {
label: string;
release: ReleaseInfo | null;
}) {
const { t } = useTranslation();
return (
<SettingRow
label={label}
description={
release
? new Date(release.publishedAt).toLocaleDateString()
: t("settings.updates.noRelease")
}
>
{release && (
<a
href={release.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs font-medium text-accent-brand hover:underline"
>
v{release.version}
<ExternalLink className="size-3" />
</a>
)}
</SettingRow>
);
}
export function UpdatesSettings() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannelState | null>(null);
const [releases, setReleases] = useState<{
stable: ReleaseInfo | null;
beta: ReleaseInfo | null;
} | null>(null);
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
const [busy, setBusy] = useState(false);
const version = import.meta.env.VITE_APP_VERSION || undefined;
const loadPlugins = useCallback(async () => {
try {
setPlugins(await getPlugins());
} catch {
setPlugins([]);
}
}, []);
useEffect(() => {
getUpdateChannel()
.then(setChannel)
.catch(() => setChannel(null));
getChannelReleases()
.then(setReleases)
.catch(() => setReleases({ stable: null, beta: null }));
void loadPlugins();
}, [loadPlugins]);
const betaPlugins = plugins.filter(onBeta);
const runningBetas = plugins
.filter((plugin) => isPrerelease(plugin.version))
.map((plugin) => ({ id: plugin.id, version: plugin.version }));
const joined = channel?.channel === "beta";
const changeChannel = async (next: boolean) => {
setBusy(true);
try {
setChannel(await setUpdateChannel(next ? "beta" : "stable"));
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
setBusy(false);
}
};
const changePlugins = async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
await loadPlugins();
setBusy(false);
}
};
const copyImage = async () => {
try {
await navigator.clipboard.writeText(DOCKER_BETA_IMAGE);
toast.success(t("settings.updates.copied"));
} catch {
toast.error(t("settings.updates.copyFailed"));
}
};
return (
<div className="flex flex-col gap-3">
<SectionCard
title={t("settings.updates.betaProgram")}
icon={<FlaskConical className="size-3.5" />}
rowId="updates-beta"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.betaIntro")}
</p>
<SettingRow
label={t("settings.updates.termixBetas")}
description={
channel?.runningBeta
? t("settings.updates.runningBetaBuild")
: t("settings.updates.termixBetasHint")
}
>
<FakeSwitch
checked={joined}
disabled={busy || !channel || channel.runningBeta}
onChange={(next) => void changeChannel(next)}
/>
</SettingRow>
{joined && !channel?.runningBeta && (
<div className="flex flex-col gap-2 py-3 text-xs">
<span className="font-medium">
{t("settings.updates.howToInstall")}
</span>
{isElectron() ? (
<span className="text-muted-foreground">
{t("settings.updates.desktopInstall")}{" "}
{releases?.beta && (
<a
href={releases.beta.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent-brand hover:underline"
>
{t("settings.updates.downloadBeta", {
version: releases.beta.version,
})}
</a>
)}
</span>
) : (
<>
<span className="text-muted-foreground">
{t("settings.updates.dockerInstall")}
</span>
<div className="flex items-center gap-2">
<code className="flex-1 truncate border border-border bg-muted/40 px-2 py-1.5 font-mono text-[11px]">
{DOCKER_BETA_IMAGE}
</code>
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5"
onClick={() => void copyImage()}
>
<Copy className="size-3.5" />
{t("settings.updates.copy")}
</Button>
</div>
</>
)}
<a
href={BETA_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent-brand hover:underline"
>
{t("settings.updates.learnMore")}
<ExternalLink className="size-3" />
</a>
</div>
)}
<SettingRow
label={t("settings.updates.feedback")}
description={t("settings.updates.feedbackHint")}
>
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={reportBetaFeedbackUrl(version, runningBetas)}
target="_blank"
rel="noopener noreferrer"
>
{t("settings.updates.sendFeedback")}
</a>
</Button>
</SettingRow>
</SectionCard>
<SectionCard
title={t("settings.updates.releases")}
icon={<Tag className="size-3.5" />}
rowId="updates-releases"
>
<SettingRow label={t("settings.updates.thisVersion")}>
<span className="text-xs font-medium">
{version ? `v${version}` : "-"}
</span>
</SettingRow>
<ReleaseRow
label={t("settings.updates.newestStable")}
release={releases?.stable ?? null}
/>
<ReleaseRow
label={t("settings.updates.newestBeta")}
release={releases?.beta ?? null}
/>
</SectionCard>
<SectionCard
title={t("settings.updates.pluginBetas")}
icon={<Puzzle className="size-3.5" />}
rowId="updates-plugins"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.pluginBetasIntro")}{" "}
<button
type="button"
className="text-accent-brand hover:underline"
onClick={() =>
window.dispatchEvent(new CustomEvent(OPEN_PLUGINS_EVENT))
}
>
{t("settings.updates.openPlugins")}
</button>
</p>
{betaPlugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={plugin.name}
description={
plugin.channel === "beta"
? `v${plugin.version}`
: t("settings.updates.waitingForStable", {
version: plugin.version,
})
}
>
{plugin.channel === "beta" && (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() =>
setPluginOptions(plugin.id, { channel: "stable" }),
)
}
>
{t("settings.updates.backToStable")}
</Button>
)}
</SettingRow>
))}
{plugins.length > 0 && (
<div className="flex flex-wrap gap-2 py-3">
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() => setAllPluginChannels("beta"))
}
>
{t("settings.updates.allPluginsBeta")}
</Button>
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy || betaPlugins.length === 0}
onClick={() =>
void changePlugins(() => setAllPluginChannels("stable"))
}
>
{t("settings.updates.allPluginsStable")}
</Button>
</div>
)}
</SectionCard>
</div>
);
}
+8
View File
@@ -1,6 +1,7 @@
import {
Database,
FileText,
FlaskConical,
Globe,
KeyRound,
Keyboard,
@@ -116,6 +117,13 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
admin: true,
adminSection: "general",
}),
core({
id: "updates",
band: "instance",
labelKey: "settings.updates.title",
icon: FlaskConical,
admin: true,
}),
core({
id: "admin-users",
band: "instance",
+37 -4
View File
@@ -4,10 +4,6 @@
export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"account": [
"newUi.sidebar.userProfile.authMethodLabel",
"newUi.sidebar.userProfile.betaProgramDescription",
"newUi.sidebar.userProfile.betaProgramFeedback",
"newUi.sidebar.userProfile.betaProgramLearnMore",
"newUi.sidebar.userProfile.betaProgramTitle",
"newUi.sidebar.userProfile.cliDescription",
"newUi.sidebar.userProfile.cliLearnMore",
"newUi.sidebar.userProfile.cliTitle",
@@ -19,9 +15,15 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"newUi.sidebar.userProfile.linkedProfileDescription",
"newUi.sidebar.userProfile.linkedProfileTitle",
"newUi.sidebar.userProfile.manageSync",
"newUi.sidebar.userProfile.onBetaDescription",
"newUi.sidebar.userProfile.onBetaTitle",
"newUi.sidebar.userProfile.openUpdates",
"newUi.sidebar.userProfile.roleLabel",
"newUi.sidebar.userProfile.sectionAccount",
"newUi.sidebar.userProfile.sendBetaFeedback",
"newUi.sidebar.userProfile.setUpSync",
"newUi.sidebar.userProfile.tryBetaDescription",
"newUi.sidebar.userProfile.tryBetaTitle",
"newUi.sidebar.userProfile.twoFaLabel",
"newUi.sidebar.userProfile.twoFaOff",
"newUi.sidebar.userProfile.twoFaOn",
@@ -431,4 +433,35 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"newUi.sidebar.userProfile.sectionSecurity",
"newUi.sidebar.userProfile.updatePassword",
],
"updates": [
"settings.updates.allPluginsBeta",
"settings.updates.allPluginsStable",
"settings.updates.backToStable",
"settings.updates.betaIntro",
"settings.updates.betaProgram",
"settings.updates.copied",
"settings.updates.copy",
"settings.updates.copyFailed",
"settings.updates.desktopInstall",
"settings.updates.dockerInstall",
"settings.updates.downloadBeta",
"settings.updates.feedback",
"settings.updates.feedbackHint",
"settings.updates.howToInstall",
"settings.updates.learnMore",
"settings.updates.newestBeta",
"settings.updates.newestStable",
"settings.updates.noRelease",
"settings.updates.openPlugins",
"settings.updates.pluginBetas",
"settings.updates.pluginBetasIntro",
"settings.updates.releases",
"settings.updates.runningBetaBuild",
"settings.updates.saveFailed",
"settings.updates.sendFeedback",
"settings.updates.termixBetas",
"settings.updates.termixBetasHint",
"settings.updates.thisVersion",
"settings.updates.waitingForStable",
],
};
+13 -1
View File
@@ -13,6 +13,7 @@ import {
import {
Bug,
Clock,
FlaskConical,
Folder,
Globe,
KeyRound,
@@ -43,7 +44,8 @@ import {
type PaletteItemDef,
} from "./palette-registry";
import { activityTarget } from "@/lib/activity-types";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url";
import { isPrerelease } from "@/plugins/plugin-model";
import { shell } from "@/plugin-host/shell-bridge";
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
@@ -234,6 +236,16 @@ export function CommandPalette({
icon: Bug,
url: reportCoreIssueUrl(import.meta.env.VITE_APP_VERSION || undefined),
},
...(isPrerelease(import.meta.env.VITE_APP_VERSION)
? [
{
id: "link-beta-feedback",
label: t("newUi.sidebar.userProfile.sendBetaFeedback"),
icon: FlaskConical,
url: reportBetaFeedbackUrl(import.meta.env.VITE_APP_VERSION),
},
]
: []),
].filter((link) => matches(link.label));
const rowClass = "group gap-2 rounded-none";
+33 -25
View File
@@ -68,7 +68,8 @@ import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { Select2 } from "@/components/select2";
import { LANGUAGES, THEMES } from "@/settings/appearance/appearance-controls";
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isPrerelease } from "@/plugins/plugin-model";
import {
FeatureSettingsSection,
featureSectionId,
@@ -464,6 +465,7 @@ export function UserProfilePanel({
const [accountUsername, setAccountUsername] = useState(username ?? "");
const [accountTotpEnabled, setAccountTotpEnabled] = useState(false);
const [userRole, setUserRole] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [authMethod, setAuthMethod] = useState("");
const [version, setVersion] = useState("");
const [versionStatus, setVersionStatus] = useState<
@@ -586,6 +588,7 @@ export function UserProfilePanel({
// A linked desktop is the account it is signed in to on the server.
setAccountUsername(linked?.username || info.username);
setAccountTotpEnabled(info.totp_enabled ?? false);
setIsAdmin(Boolean(linked ? linked.isAdmin : info.is_admin));
setUserRole(
(linked ? linked.isAdmin : info.is_admin)
? t("newUi.sidebar.userProfile.roleAdministrator")
@@ -1294,37 +1297,42 @@ export function UserProfilePanel({
</span>
</div>
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("newUi.sidebar.userProfile.betaProgramTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.betaProgramDescription")}{" "}
{(isPrerelease(version) || isAdmin) && (
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{isPrerelease(version)
? t("newUi.sidebar.userProfile.onBetaTitle")
: t("newUi.sidebar.userProfile.tryBetaTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{isPrerelease(version)
? t("newUi.sidebar.userProfile.onBetaDescription")
: t("newUi.sidebar.userProfile.tryBetaDescription")}
</span>
</div>
{isPrerelease(version) ? (
<a
href={reportCoreIssueUrl(
version || undefined,
"beta_feedback.yml",
)}
href={reportBetaFeedbackUrl(version)}
target="_blank"
rel="noopener noreferrer"
className="text-accent-brand hover:underline"
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.betaProgramFeedback")}
{t("newUi.sidebar.userProfile.sendBetaFeedback")}
</a>
</span>
) : (
<button
type="button"
onClick={() => onOpenSettingsPage?.("updates")}
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.openUpdates")}
</button>
)}
</div>
<a
href="https://docs.termix.site/install/server/docker#beta-builds"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.betaProgramLearnMore")}
</a>
</div>
</div>
)}
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
+24 -2
View File
@@ -3,6 +3,7 @@ import {
buildIssueUrl,
describeEnvironment,
normalizeGitHubRepo,
reportBetaFeedbackUrl,
reportCoreIssueUrl,
} from "@/lib/issue-url";
@@ -87,10 +88,31 @@ describe("describeEnvironment", () => {
describe("reportCoreIssueUrl", () => {
it("points at the core repo with the version filled in", () => {
const url = new URL(reportCoreIssueUrl("26.10.0", "beta_feedback.yml"));
const url = new URL(reportCoreIssueUrl("26.10.0"));
expect(url.pathname).toBe("/Termix-SSH/Termix/issues/new");
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
expect(url.searchParams.get("template")).toBe("bug_report.yml");
expect(url.searchParams.get("termix-version")).toBe("26.10.0");
expect(url.searchParams.get("environment")).toBeTruthy();
});
});
describe("reportBetaFeedbackUrl", () => {
it("uses the beta form and lists plugins on a beta", () => {
const url = new URL(
reportBetaFeedbackUrl("26.11.0-beta.2", [
{ id: "docker", version: "1.2.0-beta.1" },
{ id: "tunnels", version: "1.1.0-beta.3" },
]),
);
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
expect(url.searchParams.get("termix-version")).toBe("26.11.0-beta.2");
expect(url.searchParams.get("beta-plugins")).toBe(
"docker 1.2.0-beta.1\ntunnels 1.1.0-beta.3",
);
});
it("leaves the plugin list out when none are on a beta", () => {
const url = new URL(reportBetaFeedbackUrl("26.11.0-beta.2"));
expect(url.searchParams.has("beta-plugins")).toBe(false);
});
});
+50
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { PluginSummary, RegistryPluginEntry } from "@/api/plugins-api";
import {
betaFeedbackUrl,
betaToTry,
isPrerelease,
categoriesOf,
describeContributions,
formatBytes,
@@ -416,3 +419,50 @@ describe("youtubeEmbedUrl", () => {
}
});
});
describe("betas", () => {
it("knows a prerelease", () => {
expect(isPrerelease("1.2.0-beta.1")).toBe(true);
expect(isPrerelease("1.2.0")).toBe(false);
expect(isPrerelease(null)).toBe(false);
});
it("offers a beta only when newer than what is installed on stable", () => {
const base = {
installed: true,
version: "1.1.0",
latestBeta: "1.2.0-beta.1",
channel: "stable" as const,
};
expect(betaToTry(base)).toBe("1.2.0-beta.1");
expect(betaToTry({ ...base, channel: "beta" })).toBeNull();
expect(betaToTry({ ...base, version: "1.2.0-beta.1" })).toBeNull();
expect(betaToTry({ ...base, installed: false })).toBeNull();
expect(betaToTry({ ...base, latestBeta: null })).toBeNull();
});
it("sends beta feedback to the plugin's repo", () => {
const url = new URL(
betaFeedbackUrl(
{
id: "docker",
repository: "https://github.com/Termix-SSH/Plugin-Docker",
version: "1.2.0-beta.1",
lastError: null,
},
"26.11.0",
)!,
);
expect(url.pathname).toBe("/Termix-SSH/Plugin-Docker/issues/new");
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
expect(url.searchParams.get("plugin-version")).toBe("1.2.0-beta.1");
expect(
betaFeedbackUrl({
id: "x",
repository: "https://gitlab.com/a/b",
version: "1.0.0",
lastError: null,
}),
).toBeNull();
});
});