mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: beta channels for core and plugins with one stable/beta release workflow
This commit is contained in:
1 parent
6a855c0fa8
commit
f73c4b5ad6
55 files changed
+16200
-460
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user