mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: per-repo issue forms and prefilled bug reports instead of the support repo
This commit is contained in:
1 parent
15058119a3
commit
ac19183f40
18 files changed
+553
-43
No files matched your search
@@ -61,6 +61,7 @@ import {
|
||||
StatsStrip,
|
||||
type VersionStatus,
|
||||
} from "./dashboard-core-cards";
|
||||
import { reportCoreIssueUrl } from "@/lib/issue-url";
|
||||
|
||||
export { HostStatusList as HostStatusCard } from "./dashboard-core-cards";
|
||||
|
||||
@@ -795,8 +796,8 @@ export function DashboardTab({
|
||||
label: t("dashboard.github"),
|
||||
},
|
||||
{
|
||||
href: "https://github.com/Termix-SSH/Support",
|
||||
label: t("dashboard.support"),
|
||||
href: reportCoreIssueUrl(import.meta.env.VITE_APP_VERSION || undefined),
|
||||
label: t("dashboard.reportBug"),
|
||||
},
|
||||
{
|
||||
href: "https://discord.com/invite/jVQGdvHDrf",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
export const CORE_REPO_URL = "https://github.com/Termix-SSH/Termix";
|
||||
|
||||
const MAX_URL_LENGTH = 7500;
|
||||
|
||||
/** A GitHub repo URL without .git or a trailing slash, or null if it is not one. */
|
||||
export function normalizeGitHubRepo(repository?: string | null): string | null {
|
||||
const repo = repository
|
||||
?.trim()
|
||||
.replace(/\.git$/, "")
|
||||
.replace(/\/+$/, "");
|
||||
if (!repo || !/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(repo)) {
|
||||
return null;
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new issue from one of the repo's issue forms, with fields prefilled by
|
||||
* their form ids. Logs get cut first when the URL would be too long.
|
||||
*/
|
||||
export function buildIssueUrl(
|
||||
repo: string,
|
||||
options: {
|
||||
template: string;
|
||||
title?: string;
|
||||
fields?: Record<string, string | null | undefined>;
|
||||
},
|
||||
): string {
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(options.fields ?? {}).filter(
|
||||
(entry): entry is [string, string] => !!entry[1]?.trim(),
|
||||
),
|
||||
);
|
||||
const build = () => {
|
||||
const params = new URLSearchParams({ template: options.template });
|
||||
if (options.title) params.set("title", options.title);
|
||||
for (const [key, value] of Object.entries(fields)) params.set(key, value);
|
||||
return `${repo}/issues/new?${params.toString()}`;
|
||||
};
|
||||
let url = build();
|
||||
while (url.length > MAX_URL_LENGTH && fields.logs) {
|
||||
const cut = Math.max(0, fields.logs.length - (url.length - MAX_URL_LENGTH));
|
||||
fields.logs =
|
||||
cut > 200 ? `${fields.logs.slice(0, cut - 20)}\n...(cut)` : undefined;
|
||||
if (!fields.logs) delete fields.logs;
|
||||
url = build();
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function osName(ua: string): string {
|
||||
if (/Android/i.test(ua)) return "Android";
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return "iOS";
|
||||
if (/Windows/i.test(ua)) return "Windows";
|
||||
if (/Mac OS X|Macintosh/i.test(ua)) return "macOS";
|
||||
if (/CrOS/i.test(ua)) return "ChromeOS";
|
||||
if (/Linux/i.test(ua)) return "Linux";
|
||||
return "Unknown OS";
|
||||
}
|
||||
|
||||
function browserName(ua: string): string {
|
||||
const match =
|
||||
ua.match(/(Edg)\/(\d+)/) ??
|
||||
ua.match(/(OPR)\/(\d+)/) ??
|
||||
ua.match(/(Firefox)\/(\d+)/) ??
|
||||
ua.match(/(Chrome)\/(\d+)/) ??
|
||||
ua.match(/Version\/(\d+).*(Safari)/);
|
||||
if (!match) return "Unknown browser";
|
||||
if (match[2] === "Safari") return `Safari ${match[1]}`;
|
||||
const names: Record<string, string> = { Edg: "Edge", OPR: "Opera" };
|
||||
return `${names[match[1]] ?? match[1]} ${match[2]}`;
|
||||
}
|
||||
|
||||
/** One line on where Termix is running, for the environment field. */
|
||||
export function describeEnvironment(
|
||||
userAgent: string = typeof navigator === "undefined"
|
||||
? ""
|
||||
: navigator.userAgent,
|
||||
desktop: boolean = isElectron(),
|
||||
): string {
|
||||
const os = osName(userAgent);
|
||||
return desktop
|
||||
? `Desktop app on ${os}`
|
||||
: `Web, ${browserName(userAgent)} on ${os}`;
|
||||
}
|
||||
|
||||
/** A bug report on the Termix core repo. */
|
||||
export function reportCoreIssueUrl(
|
||||
termixVersion?: string,
|
||||
template: "bug_report.yml" | "beta_feedback.yml" = "bug_report.yml",
|
||||
): string {
|
||||
return buildIssueUrl(CORE_REPO_URL, {
|
||||
template,
|
||||
fields: {
|
||||
"termix-version": termixVersion,
|
||||
environment: describeEnvironment(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1172,7 +1172,7 @@
|
||||
"title": "Dashboard",
|
||||
"loading": "Loading dashboard...",
|
||||
"github": "GitHub",
|
||||
"support": "Support",
|
||||
"reportBug": "Report a bug",
|
||||
"discord": "Discord",
|
||||
"docs": "Docs",
|
||||
"donate": "Donate",
|
||||
|
||||
@@ -16,6 +16,11 @@ import type {
|
||||
RegistryPluginEntry,
|
||||
RegistryPluginVersion,
|
||||
} from "@/api/plugins-api";
|
||||
import {
|
||||
buildIssueUrl,
|
||||
describeEnvironment,
|
||||
normalizeGitHubRepo,
|
||||
} from "@/lib/issue-url";
|
||||
|
||||
export type PluginStatus = "running" | "disabled" | "failed" | "blocked";
|
||||
|
||||
@@ -314,28 +319,48 @@ export function formatCount(count: number): string {
|
||||
|
||||
/**
|
||||
* A new GitHub issue on the plugin's own repo with what a maintainer asks
|
||||
* first already filled in. Null when the repository is not on GitHub.
|
||||
* first already filled in. Official plugins use the org bug form; a third
|
||||
* party repo may not have it, so it gets a plain prefilled body. Null when
|
||||
* the repository is not on GitHub.
|
||||
*/
|
||||
export function reportIssueUrl(
|
||||
plugin: Pick<
|
||||
PluginEntry,
|
||||
"id" | "repository" | "version" | "latestVersion" | "status" | "source"
|
||||
| "id"
|
||||
| "repository"
|
||||
| "version"
|
||||
| "latestVersion"
|
||||
| "status"
|
||||
| "source"
|
||||
| "lastError"
|
||||
>,
|
||||
termixVersion?: string,
|
||||
): string | null {
|
||||
const repo = plugin.repository
|
||||
?.trim()
|
||||
.replace(/\.git$/, "")
|
||||
.replace(/\/+$/, "");
|
||||
if (!repo || !/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(repo)) {
|
||||
return null;
|
||||
const repo = normalizeGitHubRepo(plugin.repository);
|
||||
if (!repo) return null;
|
||||
const version = plugin.version ?? plugin.latestVersion ?? "not installed";
|
||||
const state = `Plugin ${plugin.id}, ${plugin.source}, ${plugin.status ?? "not installed"}`;
|
||||
if (plugin.source !== "unverified") {
|
||||
return buildIssueUrl(repo, {
|
||||
template: "bug_report.yml",
|
||||
fields: {
|
||||
"termix-version": termixVersion,
|
||||
"plugin-version": version,
|
||||
environment: `${describeEnvironment()}\n${state}`,
|
||||
logs: plugin.lastError,
|
||||
},
|
||||
});
|
||||
}
|
||||
const lines = [
|
||||
`**Plugin:** ${plugin.id}`,
|
||||
`**Version:** ${plugin.version ?? plugin.latestVersion ?? "not installed"}`,
|
||||
`**Version:** ${version}`,
|
||||
`**Source:** ${plugin.source}`,
|
||||
`**State:** ${plugin.status ?? "not installed"}`,
|
||||
...(termixVersion ? [`**Termix:** ${termixVersion}`] : []),
|
||||
`**Environment:** ${describeEnvironment()}`,
|
||||
...(plugin.lastError
|
||||
? ["", "**Error:**", "```", plugin.lastError, "```"]
|
||||
: []),
|
||||
"",
|
||||
"**What happened:**",
|
||||
"",
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
CommandEmpty,
|
||||
} from "@/components/command";
|
||||
import {
|
||||
Bug,
|
||||
Clock,
|
||||
Folder,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LifeBuoy,
|
||||
MessagesSquare,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type PaletteItemDef,
|
||||
} from "./palette-registry";
|
||||
import { activityTarget } from "@/lib/activity-types";
|
||||
import { reportCoreIssueUrl } from "@/lib/issue-url";
|
||||
import { shell } from "@/plugin-host/shell-bridge";
|
||||
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
|
||||
|
||||
@@ -228,10 +229,10 @@ export function CommandPalette({
|
||||
url: "https://discord.com/invite/jVQGdvHDrf",
|
||||
},
|
||||
{
|
||||
id: "link-support",
|
||||
label: t("dashboard.support"),
|
||||
icon: LifeBuoy,
|
||||
url: "https://github.com/Termix-SSH/Support/issues/new",
|
||||
id: "link-report-bug",
|
||||
label: t("dashboard.reportBug"),
|
||||
icon: Bug,
|
||||
url: reportCoreIssueUrl(import.meta.env.VITE_APP_VERSION || undefined),
|
||||
},
|
||||
].filter((link) => matches(link.label));
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ 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 {
|
||||
FeatureSettingsSection,
|
||||
featureSectionId,
|
||||
@@ -1302,7 +1303,10 @@ export function UserProfilePanel({
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.betaProgramDescription")}{" "}
|
||||
<a
|
||||
href="https://github.com/Termix-SSH/Support/issues/new?template=beta_feedback.yml"
|
||||
href={reportCoreIssueUrl(
|
||||
version || undefined,
|
||||
"beta_feedback.yml",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildIssueUrl,
|
||||
describeEnvironment,
|
||||
normalizeGitHubRepo,
|
||||
reportCoreIssueUrl,
|
||||
} from "@/lib/issue-url";
|
||||
|
||||
const CHROME_WIN =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
|
||||
const SAFARI_MAC =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15";
|
||||
const FIREFOX_LINUX =
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0";
|
||||
|
||||
describe("normalizeGitHubRepo", () => {
|
||||
it("strips .git and trailing slashes", () => {
|
||||
expect(normalizeGitHubRepo(" https://github.com/a/b.git ")).toBe(
|
||||
"https://github.com/a/b",
|
||||
);
|
||||
expect(normalizeGitHubRepo("https://github.com/a/b/")).toBe(
|
||||
"https://github.com/a/b",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects anything that is not a GitHub repo", () => {
|
||||
expect(normalizeGitHubRepo("https://gitlab.com/a/b")).toBeNull();
|
||||
expect(normalizeGitHubRepo("https://github.com/a")).toBeNull();
|
||||
expect(normalizeGitHubRepo(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildIssueUrl", () => {
|
||||
it("sets the template and skips empty fields", () => {
|
||||
const url = new URL(
|
||||
buildIssueUrl("https://github.com/a/b", {
|
||||
template: "bug_report.yml",
|
||||
fields: { "termix-version": "26.10.0", logs: "", other: undefined },
|
||||
}),
|
||||
);
|
||||
expect(url.pathname).toBe("/a/b/issues/new");
|
||||
expect(url.searchParams.get("template")).toBe("bug_report.yml");
|
||||
expect(url.searchParams.get("termix-version")).toBe("26.10.0");
|
||||
expect(url.searchParams.has("logs")).toBe(false);
|
||||
expect(url.searchParams.has("other")).toBe(false);
|
||||
});
|
||||
|
||||
it("encodes values safely", () => {
|
||||
const url = new URL(
|
||||
buildIssueUrl("https://github.com/a/b", {
|
||||
template: "bug_report.yml",
|
||||
fields: { environment: "a & b\nc=d" },
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("environment")).toBe("a & b\nc=d");
|
||||
});
|
||||
|
||||
it("cuts long logs to keep the URL short", () => {
|
||||
const url = buildIssueUrl("https://github.com/a/b", {
|
||||
template: "bug_report.yml",
|
||||
fields: { logs: "x".repeat(20000) },
|
||||
});
|
||||
expect(url.length).toBeLessThanOrEqual(7500);
|
||||
expect(new URL(url).searchParams.get("logs")).toMatch(/\(cut\)$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeEnvironment", () => {
|
||||
it("names the browser and OS on the web", () => {
|
||||
expect(describeEnvironment(CHROME_WIN, false)).toBe(
|
||||
"Web, Chrome 141 on Windows",
|
||||
);
|
||||
expect(describeEnvironment(SAFARI_MAC, false)).toBe(
|
||||
"Web, Safari 18 on macOS",
|
||||
);
|
||||
expect(describeEnvironment(FIREFOX_LINUX, false)).toBe(
|
||||
"Web, Firefox 130 on Linux",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the desktop app", () => {
|
||||
expect(describeEnvironment(CHROME_WIN, true)).toBe(
|
||||
"Desktop app on Windows",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportCoreIssueUrl", () => {
|
||||
it("points at the core repo with the version filled in", () => {
|
||||
const url = new URL(reportCoreIssueUrl("26.10.0", "beta_feedback.yml"));
|
||||
expect(url.pathname).toBe("/Termix-SSH/Termix/issues/new");
|
||||
expect(url.searchParams.get("template")).toBe("beta_feedback.yml");
|
||||
expect(url.searchParams.get("termix-version")).toBe("26.10.0");
|
||||
expect(url.searchParams.get("environment")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -248,40 +248,51 @@ describe("source and trust", () => {
|
||||
});
|
||||
|
||||
describe("reportIssueUrl", () => {
|
||||
it("prefills id, version, source and state on the plugin repo", () => {
|
||||
const url = reportIssueUrl(
|
||||
{
|
||||
id: "docker",
|
||||
repository: "https://github.com/Termix-SSH/Plugin-Docker.git",
|
||||
version: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
status: "failed",
|
||||
source: "official",
|
||||
},
|
||||
"26.10.0",
|
||||
)!;
|
||||
const parsed = new URL(url);
|
||||
const base = {
|
||||
id: "docker",
|
||||
repository: "https://github.com/Termix-SSH/Plugin-Docker.git",
|
||||
version: "1.0.0",
|
||||
latestVersion: "1.1.0",
|
||||
status: "failed" as const,
|
||||
source: "official" as const,
|
||||
lastError: "boom",
|
||||
};
|
||||
|
||||
it("fills the bug form on an official plugin repo", () => {
|
||||
const parsed = new URL(reportIssueUrl(base, "26.10.0")!);
|
||||
expect(parsed.origin + parsed.pathname).toBe(
|
||||
"https://github.com/Termix-SSH/Plugin-Docker/issues/new",
|
||||
);
|
||||
expect(parsed.searchParams.get("template")).toBe("bug_report.yml");
|
||||
expect(parsed.searchParams.get("termix-version")).toBe("26.10.0");
|
||||
expect(parsed.searchParams.get("plugin-version")).toBe("1.0.0");
|
||||
expect(parsed.searchParams.get("environment")).toContain(
|
||||
"Plugin docker, official, failed",
|
||||
);
|
||||
expect(parsed.searchParams.get("logs")).toBe("boom");
|
||||
expect(parsed.searchParams.has("body")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a plain body for an unverified plugin", () => {
|
||||
const parsed = new URL(
|
||||
reportIssueUrl({ ...base, source: "unverified" }, "26.10.0")!,
|
||||
);
|
||||
expect(parsed.searchParams.has("template")).toBe(false);
|
||||
expect(parsed.searchParams.get("title")).toBe("[docker] ");
|
||||
const body = parsed.searchParams.get("body")!;
|
||||
expect(body).toContain("**Plugin:** docker");
|
||||
expect(body).toContain("**Version:** 1.0.0");
|
||||
expect(body).toContain("**Source:** official");
|
||||
expect(body).toContain("**Source:** unverified");
|
||||
expect(body).toContain("**State:** failed");
|
||||
expect(body).toContain("**Termix:** 26.10.0");
|
||||
expect(body).toContain("boom");
|
||||
});
|
||||
|
||||
it("gives nothing for a repo that is not on GitHub", () => {
|
||||
expect(
|
||||
reportIssueUrl({
|
||||
id: "x",
|
||||
...base,
|
||||
repository: "https://gitlab.com/a/b",
|
||||
version: null,
|
||||
latestVersion: null,
|
||||
status: null,
|
||||
source: "official",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user