mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: show plugin download counts only and stop CI builds inflating them
This commit is contained in:
1 parent
4fbdf15351
commit
11f4674bd2
37 files changed
+14280
-286
No files matched your search
@@ -117,6 +117,15 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||||
|
# build only fetches them when the pins change.
|
||||||
|
- name: Cache bundled plugins
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: node_modules/.cache/termix-bundled-plugins
|
||||||
|
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||||
|
enableCrossOsArchive: true
|
||||||
|
|
||||||
# A beta build has to know it is a beta, or it reports the stable version.
|
# A beta build has to know it is a beta, or it reports the stable version.
|
||||||
- name: Stamp beta version
|
- name: Stamp beta version
|
||||||
if: inputs.version_override != ''
|
if: inputs.version_override != ''
|
||||||
@@ -240,6 +249,15 @@ jobs:
|
|||||||
npm install --force @rollup/rollup-linux-arm64-gnu
|
npm install --force @rollup/rollup-linux-arm64-gnu
|
||||||
npm install --force @rollup/rollup-linux-arm-gnueabihf
|
npm install --force @rollup/rollup-linux-arm-gnueabihf
|
||||||
|
|
||||||
|
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||||
|
# build only fetches them when the pins change.
|
||||||
|
- name: Cache bundled plugins
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: node_modules/.cache/termix-bundled-plugins
|
||||||
|
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||||
|
enableCrossOsArchive: true
|
||||||
|
|
||||||
# A beta build has to know it is a beta, or it reports the stable version.
|
# A beta build has to know it is a beta, or it reports the stable version.
|
||||||
- name: Stamp beta version
|
- name: Stamp beta version
|
||||||
if: inputs.version_override != ''
|
if: inputs.version_override != ''
|
||||||
@@ -467,6 +485,15 @@ jobs:
|
|||||||
npm install dmg-license
|
npm install dmg-license
|
||||||
node scripts/install-macos-sharp.cjs
|
node scripts/install-macos-sharp.cjs
|
||||||
|
|
||||||
|
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||||
|
# build only fetches them when the pins change.
|
||||||
|
- name: Cache bundled plugins
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: node_modules/.cache/termix-bundled-plugins
|
||||||
|
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||||
|
enableCrossOsArchive: true
|
||||||
|
|
||||||
# A beta build has to know it is a beta, or it reports the stable version.
|
# A beta build has to know it is a beta, or it reports the stable version.
|
||||||
- name: Stamp beta version
|
- name: Stamp beta version
|
||||||
if: inputs.version_override != ''
|
if: inputs.version_override != ''
|
||||||
@@ -954,6 +981,15 @@ jobs:
|
|||||||
npm install dmg-license
|
npm install dmg-license
|
||||||
node scripts/install-macos-sharp.cjs
|
node scripts/install-macos-sharp.cjs
|
||||||
|
|
||||||
|
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||||
|
# build only fetches them when the pins change.
|
||||||
|
- name: Cache bundled plugins
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: node_modules/.cache/termix-bundled-plugins
|
||||||
|
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||||
|
enableCrossOsArchive: true
|
||||||
|
|
||||||
- name: Check for Code Signing Certificates
|
- name: Check for Code Signing Certificates
|
||||||
id: check_certs
|
id: check_certs
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||||
|
# build only fetches them when the pins change.
|
||||||
|
- name: Cache bundled plugins
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: node_modules/.cache/termix-bundled-plugins
|
||||||
|
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||||
|
enableCrossOsArchive: true
|
||||||
|
|
||||||
- name: Build plugin SDK
|
- name: Build plugin SDK
|
||||||
run: npm run build:sdk
|
run: npm run build:sdk
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,15 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||||
|
# build only fetches them when the pins change.
|
||||||
|
- name: Cache bundled plugins
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: node_modules/.cache/termix-bundled-plugins
|
||||||
|
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
|
||||||
|
enableCrossOsArchive: true
|
||||||
|
|
||||||
- name: Build plugin SDK
|
- name: Build plugin SDK
|
||||||
run: npm run build:sdk
|
run: npm run build:sdk
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
- Every language is now fully translated, in Termix and in every plugin, and stays up to date with each release
|
- Every language is now fully translated, in Termix and in every plugin, and stays up to date with each release
|
||||||
- Languages with more than two plural forms, like Russian, Polish and Arabic, now use the right one
|
- Languages with more than two plural forms, like Russian, Polish and Arabic, now use the right one
|
||||||
|
- The Plugins tab shows how many times each plugin was downloaded, in a Downloads column
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
- Plugin updates synced to the desktop app failed to start with "Cannot find package"
|
- Plugin updates synced to the desktop app failed to start with "Cannot find package"
|
||||||
- The desktop app showed plugins that only run on the server as not running and out of date
|
- The desktop app showed plugins that only run on the server as not running and out of date
|
||||||
- Keep the terminal WebSocket open past nginx's 60s default
|
- Keep the terminal WebSocket open past nginx's 60s default
|
||||||
|
- Plugin download counts now show in the Plugins tab without waiting for the background check
|
||||||
|
|
||||||
## 26.10.0
|
## 26.10.0
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -34,7 +34,9 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build:backend
|
# Bundled plugin downloads show in the plugin store, so keep them between builds.
|
||||||
|
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
|
||||||
|
npm run build:backend
|
||||||
|
|
||||||
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||||
FROM node:24-slim AS opkssh-downloader
|
FROM node:24-slim AS opkssh-downloader
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -498,6 +498,13 @@
|
|||||||
"when": 1791550497926,
|
"when": 1791550497926,
|
||||||
"tag": "0070_drop_donation_modal",
|
"tag": "0070_drop_donation_modal",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 71,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1791755801066,
|
||||||
|
"tag": "0071_drop_install_count_source",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "plugin_install_counts" DROP COLUMN "source";
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -498,6 +498,13 @@
|
|||||||
"when": 1791550496661,
|
"when": 1791550496661,
|
||||||
"tag": "0070_drop_donation_modal",
|
"tag": "0070_drop_donation_modal",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 71,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791755800419,
|
||||||
|
"tag": "0071_drop_install_count_source",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -463,6 +463,13 @@
|
|||||||
"when": 1791550495357,
|
"when": 1791550495357,
|
||||||
"tag": "0065_drop_donation_modal",
|
"tag": "0065_drop_donation_modal",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 66,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1791755799726,
|
||||||
|
"tag": "0066_drop_install_count_source",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+1
-1
@@ -369,7 +369,7 @@
|
|||||||
{
|
{
|
||||||
"name": "TERMIX_PLUGIN_STATS_URL",
|
"name": "TERMIX_PLUGIN_STATS_URL",
|
||||||
"group": "plugins",
|
"group": "plugins",
|
||||||
"description": "Where install counts are read from. Defaults to stats.json next to the registry index."
|
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
|
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
|
||||||
|
|||||||
@@ -542,7 +542,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
plugin_id TEXT NOT NULL,
|
plugin_id TEXT NOT NULL,
|
||||||
registry_id TEXT NOT NULL,
|
registry_id TEXT NOT NULL,
|
||||||
count INTEGER NOT NULL DEFAULT 0,
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
source TEXT NOT NULL DEFAULT 'aggregate-telemetry',
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE (plugin_id, registry_id)
|
UNIQUE (plugin_id, registry_id)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export const RETIRED_COLUMNS: Record<string, string[]> = {
|
|||||||
"donation_modal_dismissed",
|
"donation_modal_dismissed",
|
||||||
],
|
],
|
||||||
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
|
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
|
||||||
|
// 26.10.1 shows downloads only.
|
||||||
|
plugin_install_counts: ["source"],
|
||||||
user_preferences: [
|
user_preferences: [
|
||||||
"command_autocomplete",
|
"command_autocomplete",
|
||||||
"terminal_defaults",
|
"terminal_defaults",
|
||||||
|
|||||||
@@ -1055,10 +1055,9 @@ export const pluginRegistries = mysqlTable("plugin_registries", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install counts populated by a background job (GitHub release download
|
* Download counts from each registry's stats.json, written by a background
|
||||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
* job rather than by installs, kept separate from `plugins` so that job can
|
||||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
* overwrite counts without touching install state.
|
||||||
* that job can overwrite counts without touching install state.
|
|
||||||
*/
|
*/
|
||||||
export const pluginInstallCounts = mysqlTable(
|
export const pluginInstallCounts = mysqlTable(
|
||||||
"plugin_install_counts",
|
"plugin_install_counts",
|
||||||
@@ -1067,7 +1066,6 @@ export const pluginInstallCounts = mysqlTable(
|
|||||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||||
count: int("count").notNull().default(0),
|
count: int("count").notNull().default(0),
|
||||||
source: text("source").notNull().default("aggregate-telemetry"),
|
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
|||||||
@@ -1055,10 +1055,9 @@ export const pluginRegistries = pgTable("plugin_registries", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install counts populated by a background job (GitHub release download
|
* Download counts from each registry's stats.json, written by a background
|
||||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
* job rather than by installs, kept separate from `plugins` so that job can
|
||||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
* overwrite counts without touching install state.
|
||||||
* that job can overwrite counts without touching install state.
|
|
||||||
*/
|
*/
|
||||||
export const pluginInstallCounts = pgTable(
|
export const pluginInstallCounts = pgTable(
|
||||||
"plugin_install_counts",
|
"plugin_install_counts",
|
||||||
@@ -1067,7 +1066,6 @@ export const pluginInstallCounts = pgTable(
|
|||||||
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
|
||||||
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
registryId: varchar("registry_id", { length: 255 }).notNull(),
|
||||||
count: integer("count").notNull().default(0),
|
count: integer("count").notNull().default(0),
|
||||||
source: text("source").notNull().default("aggregate-telemetry"),
|
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -1051,10 +1051,9 @@ export const pluginRegistries = sqliteTable("plugin_registries", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install counts populated by a background job (GitHub release download
|
* Download counts from each registry's stats.json, written by a background
|
||||||
* counts, aggregated telemetry, or a manual override) rather than by the
|
* job rather than by installs, kept separate from `plugins` so that job can
|
||||||
* install/uninstall actions themselves, kept separate from `plugins` so
|
* overwrite counts without touching install state.
|
||||||
* that job can overwrite counts without touching install state.
|
|
||||||
*/
|
*/
|
||||||
export const pluginInstallCounts = sqliteTable(
|
export const pluginInstallCounts = sqliteTable(
|
||||||
"plugin_install_counts",
|
"plugin_install_counts",
|
||||||
@@ -1063,7 +1062,6 @@ export const pluginInstallCounts = sqliteTable(
|
|||||||
pluginId: text("plugin_id").notNull(),
|
pluginId: text("plugin_id").notNull(),
|
||||||
registryId: text("registry_id").notNull(),
|
registryId: text("registry_id").notNull(),
|
||||||
count: integer("count").notNull().default(0),
|
count: integer("count").notNull().default(0),
|
||||||
source: text("source").notNull().default("aggregate-telemetry"),
|
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
|
|||||||
export interface PluginInstallCountInput {
|
export interface PluginInstallCountInput {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
count: number;
|
count: number;
|
||||||
source: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Popularity numbers for the store, one row per plugin and registry. */
|
/** Download counts for the store, one row per plugin and registry. */
|
||||||
export class PluginInstallCountRepository {
|
export class PluginInstallCountRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly context: DatabaseContext,
|
private readonly context: DatabaseContext,
|
||||||
@@ -38,13 +37,11 @@ export class PluginInstallCountRepository {
|
|||||||
let written = 0;
|
let written = 0;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const row = existing.get(entry.pluginId);
|
const row = existing.get(entry.pluginId);
|
||||||
if (row && row.count === entry.count && row.source === entry.source) {
|
if (row && row.count === entry.count) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (row) {
|
if (row) {
|
||||||
await this.context.drizzle
|
await this.context.drizzle
|
||||||
.update(pluginInstallCounts)
|
.update(pluginInstallCounts)
|
||||||
.set({ count: entry.count, source: entry.source, updatedAt: now })
|
.set({ count: entry.count, updatedAt: now })
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(pluginInstallCounts.pluginId, entry.pluginId),
|
eq(pluginInstallCounts.pluginId, entry.pluginId),
|
||||||
@@ -56,7 +53,6 @@ export class PluginInstallCountRepository {
|
|||||||
pluginId: entry.pluginId,
|
pluginId: entry.pluginId,
|
||||||
registryId,
|
registryId,
|
||||||
count: entry.count,
|
count: entry.count,
|
||||||
source: entry.source,
|
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* The registry timer: refreshes the store's install counts and applies
|
* The registry timer: refreshes the store's download counts and applies
|
||||||
* plugin updates for plugins with auto-update on. An update that asks for a
|
* plugin updates for plugins with auto-update on. An update that asks for a
|
||||||
* new capability is never applied here; admins get an alert and review it in
|
* new capability is never applied here; admins get an alert and review it in
|
||||||
* the Plugins tab. Nothing is ever installed from here.
|
* the Plugins tab. Nothing is ever installed from here.
|
||||||
@@ -13,8 +13,8 @@ const CHECK_EVERY_MS = 6 * 60 * 60_000;
|
|||||||
let timer: NodeJS.Timeout | null = null;
|
let timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
export async function runPluginAutoUpdate(): Promise<void> {
|
export async function runPluginAutoUpdate(): Promise<void> {
|
||||||
const { syncInstallCounts } = await import("./install-counts.js");
|
const { syncDownloadCounts } = await import("./download-counts.js");
|
||||||
await syncInstallCounts();
|
await syncDownloadCounts();
|
||||||
|
|
||||||
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
|
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
|
||||||
if (await isLinkedDesktop()) return;
|
if (await isLinkedDesktop()) return;
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Download counts for the plugin store, copied from the registry's
|
||||||
|
* stats.json into plugin_install_counts.
|
||||||
|
*
|
||||||
|
* A count is how often the plugin's .tmxplug release files were downloaded.
|
||||||
|
* An instance that updates three times counts three times, and copies bundled
|
||||||
|
* into Docker images or the desktop app never count. Treat it as a ranking,
|
||||||
|
* never as a user count.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
|
import {
|
||||||
|
OFFICIAL_REGISTRY_ID,
|
||||||
|
fetchRegistryStats,
|
||||||
|
officialSource,
|
||||||
|
type RegistrySource,
|
||||||
|
} from "./registry-index.js";
|
||||||
|
|
||||||
|
async function repository() {
|
||||||
|
const { createCurrentPluginInstallCountRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
return createCurrentPluginInstallCountRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
const STALE_MS = 6 * 60 * 60_000;
|
||||||
|
const FORCED_MS = 60_000;
|
||||||
|
const WAIT_MS = 3_000;
|
||||||
|
|
||||||
|
let lastSyncAt = 0;
|
||||||
|
let running: Promise<number> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs when the counts are older than the registry timer's interval, or
|
||||||
|
* older than a minute with force (the Refresh button). Waits a few seconds
|
||||||
|
* at most so a slow registry never holds up the store; a sync that runs
|
||||||
|
* longer still finishes in the background.
|
||||||
|
*/
|
||||||
|
export async function refreshDownloadCounts(force = false): Promise<void> {
|
||||||
|
const age = Date.now() - lastSyncAt;
|
||||||
|
if (!running && age >= (force ? FORCED_MS : STALE_MS)) {
|
||||||
|
running = syncDownloadCounts().finally(() => {
|
||||||
|
running = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!running) return;
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
await Promise.race([
|
||||||
|
running,
|
||||||
|
new Promise((resolve) => {
|
||||||
|
timeout = setTimeout(resolve, WAIT_MS);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. */
|
||||||
|
export function resetDownloadCountRefresh(): void {
|
||||||
|
lastSyncAt = 0;
|
||||||
|
running = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
|
||||||
|
export async function syncDownloadCounts(): Promise<number> {
|
||||||
|
lastSyncAt = Date.now();
|
||||||
|
let changed = 0;
|
||||||
|
let sources: RegistrySource[] = [officialSource()];
|
||||||
|
try {
|
||||||
|
const { listSources } = await import("./registries.js");
|
||||||
|
sources = await listSources();
|
||||||
|
} catch {
|
||||||
|
// Official only when the registry choices cannot be read.
|
||||||
|
}
|
||||||
|
for (const source of sources) {
|
||||||
|
try {
|
||||||
|
const stats = await fetchRegistryStats(source);
|
||||||
|
if (!stats) continue;
|
||||||
|
const entries = [...stats.plugins].map(([pluginId, downloads]) => ({
|
||||||
|
pluginId,
|
||||||
|
count: downloads,
|
||||||
|
}));
|
||||||
|
changed += await (await repository()).upsertMany(source.id, entries);
|
||||||
|
} catch (error) {
|
||||||
|
pluginLogger.warn(
|
||||||
|
`Could not sync plugin download counts from ${source.id}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
{ operation: "plugin_registry" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readDownloadCounts(
|
||||||
|
registryId: string = OFFICIAL_REGISTRY_ID,
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
try {
|
||||||
|
const rows = await (await repository()).listByRegistry(registryId);
|
||||||
|
return new Map(rows.map((row) => [row.pluginId, row.count]));
|
||||||
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* Popularity numbers for the plugin store, copied from the registry's
|
|
||||||
* stats.json into plugin_install_counts.
|
|
||||||
*
|
|
||||||
* Two sources, and the store prefers the first:
|
|
||||||
* - "aggregate-telemetry": instances that reported the plugin enabled in the
|
|
||||||
* last 7 days through the Usage Statistics plugin. It undercounts by every
|
|
||||||
* instance that turned the report off or never installed that plugin.
|
|
||||||
* - "github-releases": downloads of the plugin's .tmxplug release assets.
|
|
||||||
* This counts downloads, not active installs: an instance that updates
|
|
||||||
* three times counts three times, and Docker images that bundle the plugin
|
|
||||||
* count once per build, not once per install.
|
|
||||||
*
|
|
||||||
* Treat both as a rough ranking, never as a user count.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { pluginLogger } from "../utils/logger.js";
|
|
||||||
import {
|
|
||||||
OFFICIAL_REGISTRY_ID,
|
|
||||||
fetchRegistryStats,
|
|
||||||
officialSource,
|
|
||||||
type RegistrySource,
|
|
||||||
} from "./registry-index.js";
|
|
||||||
|
|
||||||
export const TELEMETRY_SOURCE = "aggregate-telemetry";
|
|
||||||
export const DOWNLOADS_SOURCE = "github-releases";
|
|
||||||
|
|
||||||
export interface InstallCount {
|
|
||||||
count: number;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function repository() {
|
|
||||||
const { createCurrentPluginInstallCountRepository } =
|
|
||||||
await import("../database/repositories/factory.js");
|
|
||||||
return createCurrentPluginInstallCountRepository();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Picks the number to show: active installs when known, downloads otherwise. */
|
|
||||||
export function preferredCount(entry: {
|
|
||||||
downloads: number;
|
|
||||||
activeInstalls: number | null;
|
|
||||||
}): InstallCount {
|
|
||||||
return entry.activeInstalls !== null && entry.activeInstalls > 0
|
|
||||||
? { count: entry.activeInstalls, source: TELEMETRY_SOURCE }
|
|
||||||
: { count: entry.downloads, source: DOWNLOADS_SOURCE };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
|
|
||||||
export async function syncInstallCounts(): Promise<number> {
|
|
||||||
let changed = 0;
|
|
||||||
let sources: RegistrySource[] = [officialSource()];
|
|
||||||
try {
|
|
||||||
const { listSources } = await import("./registries.js");
|
|
||||||
sources = await listSources();
|
|
||||||
} catch {
|
|
||||||
// Official only when the registry choices cannot be read.
|
|
||||||
}
|
|
||||||
for (const source of sources) {
|
|
||||||
try {
|
|
||||||
const stats = await fetchRegistryStats(source);
|
|
||||||
if (!stats) continue;
|
|
||||||
const entries = [...stats.plugins].map(([pluginId, entry]) => ({
|
|
||||||
pluginId,
|
|
||||||
...preferredCount(entry),
|
|
||||||
}));
|
|
||||||
changed += await (await repository()).upsertMany(source.id, entries);
|
|
||||||
} catch (error) {
|
|
||||||
pluginLogger.warn(
|
|
||||||
`Could not sync plugin install counts from ${source.id}: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
{ operation: "plugin_registry" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readInstallCounts(
|
|
||||||
registryId: string = OFFICIAL_REGISTRY_ID,
|
|
||||||
): Promise<Map<string, InstallCount>> {
|
|
||||||
try {
|
|
||||||
const rows = await (await repository()).listByRegistry(registryId);
|
|
||||||
return new Map(
|
|
||||||
rows.map((row) => [
|
|
||||||
row.pluginId,
|
|
||||||
{ count: row.count, source: row.source },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,7 +56,10 @@ import {
|
|||||||
} from "./registries.js";
|
} from "./registries.js";
|
||||||
import { invalidatePluginPermissionCache } from "./permissions.js";
|
import { invalidatePluginPermissionCache } from "./permissions.js";
|
||||||
import { moveWithRetry } from "./fs-retry.js";
|
import { moveWithRetry } from "./fs-retry.js";
|
||||||
import { readInstallCounts } from "./install-counts.js";
|
import {
|
||||||
|
readDownloadCounts,
|
||||||
|
refreshDownloadCounts,
|
||||||
|
} from "./download-counts.js";
|
||||||
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js";
|
||||||
import {
|
import {
|
||||||
parseManifest,
|
parseManifest,
|
||||||
@@ -273,9 +276,8 @@ export interface RegistryListing {
|
|||||||
pinnedVersion: string | null;
|
pinnedVersion: string | null;
|
||||||
autoUpdate: boolean;
|
autoUpdate: boolean;
|
||||||
bundled: boolean;
|
bundled: boolean;
|
||||||
/** See install-counts.ts for what this number does and does not mean. */
|
/** See download-counts.ts for what this number does and does not mean. */
|
||||||
installCount: number | null;
|
downloads: number | null;
|
||||||
installCountSource: string | null;
|
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,13 +317,14 @@ export async function listRegistry(
|
|||||||
const records = new Map(
|
const records = new Map(
|
||||||
(await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]),
|
(await createCurrentPluginRepository().listAll()).map((r) => [r.id, r]),
|
||||||
);
|
);
|
||||||
|
await refreshDownloadCounts(options.force);
|
||||||
const counts = new Map(
|
const counts = new Map(
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
sources
|
sources
|
||||||
.filter((source) => source.tier !== "custom")
|
.filter((source) => source.tier !== "custom")
|
||||||
.map(
|
.map(
|
||||||
async (source) =>
|
async (source) =>
|
||||||
[source.id, await readInstallCounts(source.id)] as const,
|
[source.id, await readDownloadCounts(source.id)] as const,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -439,9 +442,7 @@ export async function listRegistry(
|
|||||||
pinnedVersion: record?.pinnedVersion ?? null,
|
pinnedVersion: record?.pinnedVersion ?? null,
|
||||||
autoUpdate: Boolean(record?.autoUpdate),
|
autoUpdate: Boolean(record?.autoUpdate),
|
||||||
bundled: shipped.has(plugin.id),
|
bundled: shipped.has(plugin.id),
|
||||||
installCount: counts.get(source.id)?.get(plugin.id)?.count ?? null,
|
downloads: counts.get(source.id)?.get(plugin.id) ?? null,
|
||||||
installCountSource:
|
|
||||||
counts.get(source.id)?.get(plugin.id)?.source ?? null,
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -356,7 +356,8 @@ export async function fetchRegistryIndex(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RegistryStats {
|
export interface RegistryStats {
|
||||||
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
/** Downloads per plugin id. */
|
||||||
|
plugins: Map<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** stats.json sits next to index.json and is rebuilt daily by the registry. */
|
/** stats.json sits next to index.json and is rebuilt daily by the registry. */
|
||||||
@@ -380,21 +381,14 @@ function asCount(value: unknown): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseRegistryStats(raw: unknown): RegistryStats {
|
export function parseRegistryStats(raw: unknown): RegistryStats {
|
||||||
const plugins = new Map<
|
const plugins = new Map<string, number>();
|
||||||
string,
|
|
||||||
{ downloads: number; activeInstalls: number | null }
|
|
||||||
>();
|
|
||||||
const entries = (raw as { plugins?: unknown } | null)?.plugins;
|
const entries = (raw as { plugins?: unknown } | null)?.plugins;
|
||||||
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
||||||
return { plugins };
|
return { plugins };
|
||||||
}
|
}
|
||||||
for (const [id, value] of Object.entries(entries)) {
|
for (const [id, value] of Object.entries(entries)) {
|
||||||
if (!ID_PATTERN.test(id) || !value || typeof value !== "object") continue;
|
if (!ID_PATTERN.test(id) || !value || typeof value !== "object") continue;
|
||||||
const entry = value as Record<string, unknown>;
|
plugins.set(id, asCount((value as Record<string, unknown>).downloads) ?? 0);
|
||||||
plugins.set(id, {
|
|
||||||
downloads: asCount(entry.downloads) ?? 0,
|
|
||||||
activeInstalls: asCount(entry.activeInstalls),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return { plugins };
|
return { plugins };
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-74
@@ -1,25 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
const state = vi.hoisted(() => ({
|
||||||
stats: null as null | {
|
stats: null as null | { plugins: Map<string, number> },
|
||||||
plugins: Map<string, { downloads: number; activeInstalls: number | null }>;
|
|
||||||
},
|
|
||||||
fail: false,
|
fail: false,
|
||||||
sources: [{ id: "official", tier: "official" }] as Array<{
|
sources: [{ id: "official", tier: "official" }] as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
tier: string;
|
tier: string;
|
||||||
}>,
|
}>,
|
||||||
statsBySource: {} as Record<
|
statsBySource: {} as Record<string, { plugins: Map<string, number> } | null>,
|
||||||
string,
|
|
||||||
{
|
|
||||||
plugins: Map<
|
|
||||||
string,
|
|
||||||
{ downloads: number; activeInstalls: number | null }
|
|
||||||
>;
|
|
||||||
} | null
|
|
||||||
>,
|
|
||||||
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
upserted: [] as Array<{ registryId: string; entries: unknown[] }>,
|
||||||
rows: [] as Array<{ pluginId: string; count: number; source: string }>,
|
rows: [] as Array<{ pluginId: string; count: number }>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../plugins/registry-index.js", () => ({
|
vi.mock("../../plugins/registry-index.js", () => ({
|
||||||
@@ -46,12 +36,11 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
DOWNLOADS_SOURCE,
|
readDownloadCounts,
|
||||||
TELEMETRY_SOURCE,
|
refreshDownloadCounts,
|
||||||
preferredCount,
|
resetDownloadCountRefresh,
|
||||||
readInstallCounts,
|
syncDownloadCounts,
|
||||||
syncInstallCounts,
|
} = await import("../../plugins/download-counts.js");
|
||||||
} = await import("../../plugins/install-counts.js");
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
state.stats = null;
|
state.stats = null;
|
||||||
@@ -60,9 +49,62 @@ beforeEach(() => {
|
|||||||
state.statsBySource = {};
|
state.statsBySource = {};
|
||||||
state.upserted = [];
|
state.upserted = [];
|
||||||
state.rows = [];
|
state.rows = [];
|
||||||
|
resetDownloadCountRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("syncInstallCounts with more registries", () => {
|
describe("refreshDownloadCounts", () => {
|
||||||
|
const docker = () => ({ plugins: new Map([["docker", 5]]) });
|
||||||
|
|
||||||
|
it("syncs on the first look and not again while fresh", async () => {
|
||||||
|
state.stats = docker();
|
||||||
|
await refreshDownloadCounts();
|
||||||
|
await refreshDownloadCounts();
|
||||||
|
expect(state.upserted).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not sync again right away even when forced", async () => {
|
||||||
|
state.stats = docker();
|
||||||
|
await refreshDownloadCounts();
|
||||||
|
await refreshDownloadCounts(true);
|
||||||
|
expect(state.upserted).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs again when forced after a minute", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
try {
|
||||||
|
state.stats = docker();
|
||||||
|
await refreshDownloadCounts();
|
||||||
|
vi.setSystemTime(Date.now() + 61_000);
|
||||||
|
await refreshDownloadCounts();
|
||||||
|
expect(state.upserted).toHaveLength(1);
|
||||||
|
await refreshDownloadCounts(true);
|
||||||
|
expect(state.upserted).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syncDownloadCounts", () => {
|
||||||
|
it("writes one row per plugin", async () => {
|
||||||
|
state.stats = {
|
||||||
|
plugins: new Map([
|
||||||
|
["docker", 50],
|
||||||
|
["tunnels", 8],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
expect(await syncDownloadCounts()).toBe(2);
|
||||||
|
expect(state.upserted).toEqual([
|
||||||
|
{
|
||||||
|
registryId: "official",
|
||||||
|
entries: [
|
||||||
|
{ pluginId: "docker", count: 50 },
|
||||||
|
{ pluginId: "tunnels", count: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("stores counts per registry and skips custom ones", async () => {
|
it("stores counts per registry and skips custom ones", async () => {
|
||||||
state.sources = [
|
state.sources = [
|
||||||
{ id: "official", tier: "official" },
|
{ id: "official", tier: "official" },
|
||||||
@@ -70,78 +112,33 @@ describe("syncInstallCounts with more registries", () => {
|
|||||||
{ id: "mine", tier: "custom" },
|
{ id: "mine", tier: "custom" },
|
||||||
];
|
];
|
||||||
state.statsBySource = {
|
state.statsBySource = {
|
||||||
official: {
|
official: { plugins: new Map([["docker", 5]]) },
|
||||||
plugins: new Map([["docker", { downloads: 5, activeInstalls: null }]]),
|
community: { plugins: new Map([["neat", 3]]) },
|
||||||
},
|
|
||||||
community: {
|
|
||||||
plugins: new Map([["neat", { downloads: 3, activeInstalls: 2 }]]),
|
|
||||||
},
|
|
||||||
mine: null,
|
mine: null,
|
||||||
};
|
};
|
||||||
expect(await syncInstallCounts()).toBe(2);
|
expect(await syncDownloadCounts()).toBe(2);
|
||||||
expect(state.upserted.map((entry) => entry.registryId)).toEqual([
|
expect(state.upserted.map((entry) => entry.registryId)).toEqual([
|
||||||
"official",
|
"official",
|
||||||
"community",
|
"community",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("preferredCount", () => {
|
|
||||||
it("prefers active installs and falls back to downloads", () => {
|
|
||||||
expect(preferredCount({ downloads: 90, activeInstalls: 12 })).toEqual({
|
|
||||||
count: 12,
|
|
||||||
source: TELEMETRY_SOURCE,
|
|
||||||
});
|
|
||||||
expect(preferredCount({ downloads: 90, activeInstalls: null })).toEqual({
|
|
||||||
count: 90,
|
|
||||||
source: DOWNLOADS_SOURCE,
|
|
||||||
});
|
|
||||||
expect(preferredCount({ downloads: 90, activeInstalls: 0 })).toEqual({
|
|
||||||
count: 90,
|
|
||||||
source: DOWNLOADS_SOURCE,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("syncInstallCounts", () => {
|
|
||||||
it("writes one row per plugin with its preferred source", async () => {
|
|
||||||
state.stats = {
|
|
||||||
plugins: new Map([
|
|
||||||
["docker", { downloads: 50, activeInstalls: 20 }],
|
|
||||||
["tunnels", { downloads: 8, activeInstalls: null }],
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
expect(await syncInstallCounts()).toBe(2);
|
|
||||||
expect(state.upserted).toEqual([
|
|
||||||
{
|
|
||||||
registryId: "official",
|
|
||||||
entries: [
|
|
||||||
{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE },
|
|
||||||
{ pluginId: "tunnels", count: 8, source: DOWNLOADS_SOURCE },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the old numbers when the registry is unreachable", async () => {
|
it("keeps the old numbers when the registry is unreachable", async () => {
|
||||||
state.fail = true;
|
state.fail = true;
|
||||||
expect(await syncInstallCounts()).toBe(0);
|
expect(await syncDownloadCounts()).toBe(0);
|
||||||
expect(state.upserted).toEqual([]);
|
expect(state.upserted).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing for a registry with no stats file", async () => {
|
it("does nothing for a registry with no stats file", async () => {
|
||||||
expect(await syncInstallCounts()).toBe(0);
|
expect(await syncDownloadCounts()).toBe(0);
|
||||||
expect(state.upserted).toEqual([]);
|
expect(state.upserted).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("readInstallCounts", () => {
|
describe("readDownloadCounts", () => {
|
||||||
it("maps the stored rows by plugin id", async () => {
|
it("maps the stored rows by plugin id", async () => {
|
||||||
state.rows = [{ pluginId: "docker", count: 20, source: TELEMETRY_SOURCE }];
|
state.rows = [{ pluginId: "docker", count: 20 }];
|
||||||
const counts = await readInstallCounts();
|
const counts = await readDownloadCounts();
|
||||||
expect(counts.get("docker")).toEqual({
|
expect(counts.get("docker")).toBe(20);
|
||||||
count: 20,
|
|
||||||
source: TELEMETRY_SOURCE,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -512,16 +512,16 @@ describe("registry stats", () => {
|
|||||||
it("keeps valid counts and drops the rest", () => {
|
it("keeps valid counts and drops the rest", () => {
|
||||||
const stats = parseRegistryStats({
|
const stats = parseRegistryStats({
|
||||||
plugins: {
|
plugins: {
|
||||||
docker: { downloads: 120, activeInstalls: 40 },
|
docker: { downloads: 120 },
|
||||||
tunnels: { downloads: 7 },
|
tunnels: { downloads: 7 },
|
||||||
"../bad": { downloads: 1 },
|
"../bad": { downloads: 1 },
|
||||||
broken: { downloads: -3, activeInstalls: "many" },
|
broken: { downloads: -3 },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(Object.fromEntries(stats.plugins)).toEqual({
|
expect(Object.fromEntries(stats.plugins)).toEqual({
|
||||||
docker: { downloads: 120, activeInstalls: 40 },
|
docker: 120,
|
||||||
tunnels: { downloads: 7, activeInstalls: null },
|
tunnels: 7,
|
||||||
broken: { downloads: 0, activeInstalls: null },
|
broken: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -530,9 +530,6 @@ describe("registry stats", () => {
|
|||||||
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
|
response(JSON.stringify({ plugins: { docker: { downloads: 3 } } })),
|
||||||
);
|
);
|
||||||
const stats = await fetchRegistryStats();
|
const stats = await fetchRegistryStats();
|
||||||
expect(stats?.plugins.get("docker")).toEqual({
|
expect(stats?.plugins.get("docker")).toBe(3);
|
||||||
downloads: 3,
|
|
||||||
activeInstalls: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -321,9 +321,8 @@ export interface RegistryPluginEntry {
|
|||||||
pinnedVersion: string | null;
|
pinnedVersion: string | null;
|
||||||
autoUpdate: boolean;
|
autoUpdate: boolean;
|
||||||
bundled: boolean;
|
bundled: boolean;
|
||||||
/** Active installs when known, release downloads otherwise. */
|
/** How often the release files were downloaded, from the registry. */
|
||||||
installCount: number | null;
|
downloads: number | null;
|
||||||
installCountSource: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegistryListingSource {
|
export interface RegistryListingSource {
|
||||||
|
|||||||
@@ -1933,7 +1933,7 @@
|
|||||||
"version": "Version",
|
"version": "Version",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"category": "Category",
|
"category": "Category",
|
||||||
"installs": "Installs"
|
"downloads": "Downloads"
|
||||||
},
|
},
|
||||||
"risk": {
|
"risk": {
|
||||||
"critical": "Critical",
|
"critical": "Critical",
|
||||||
@@ -2145,11 +2145,8 @@
|
|||||||
"signedOnly": "TERMIX_REQUIRE_SIGNED_PLUGINS is set, so installing from a file stays blocked."
|
"signedOnly": "TERMIX_REQUIRE_SIGNED_PLUGINS is set, so installing from a file stays blocked."
|
||||||
},
|
},
|
||||||
"count": {
|
"count": {
|
||||||
"active_one": "{{formatted}} active install",
|
|
||||||
"active_other": "{{formatted}} active installs",
|
|
||||||
"downloads_one": "{{formatted}} download",
|
"downloads_one": "{{formatted}} download",
|
||||||
"downloads_other": "{{formatted}} downloads",
|
"downloads_other": "{{formatted}} downloads",
|
||||||
"activeHint": "Instances that reported this plugin turned on in the last week. Instances with usage statistics turned off are not counted.",
|
|
||||||
"downloadsHint": "How many times a release was downloaded. This is not the number of installs."
|
"downloadsHint": "How many times a release was downloaded. This is not the number of installs."
|
||||||
},
|
},
|
||||||
"unverified": "Unverified",
|
"unverified": "Unverified",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
|
|||||||
import {
|
import {
|
||||||
BetaBadge,
|
BetaBadge,
|
||||||
CapabilityRow,
|
CapabilityRow,
|
||||||
InstallCountFact,
|
DownloadCount,
|
||||||
PluginIconBox,
|
PluginIconBox,
|
||||||
SourceBadge,
|
SourceBadge,
|
||||||
UnverifiedBadge,
|
UnverifiedBadge,
|
||||||
@@ -128,10 +128,7 @@ export function PluginDetail({
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<InstallCountFact
|
<DownloadCount count={plugin.downloads} />
|
||||||
count={plugin.installCount}
|
|
||||||
source={plugin.installCountSource}
|
|
||||||
/>
|
|
||||||
</Facts>
|
</Facts>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col gap-1.5">
|
<div className="flex shrink-0 flex-col gap-1.5">
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
BetaBadge,
|
BetaBadge,
|
||||||
InstallCountFact,
|
DownloadCount,
|
||||||
PluginIconBox,
|
PluginIconBox,
|
||||||
SourceBadge,
|
SourceBadge,
|
||||||
UnverifiedBadge,
|
UnverifiedBadge,
|
||||||
@@ -992,16 +992,13 @@ function BrowseSection({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "installs",
|
key: "downloads",
|
||||||
header: t("plugins.manager.table.installs"),
|
header: t("plugins.manager.table.downloads"),
|
||||||
width: "110px",
|
width: "110px",
|
||||||
hideBelow: "lg",
|
hideBelow: "lg",
|
||||||
cell: (p) => (
|
cell: (p) => (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
<InstallCountFact
|
<DownloadCount count={p.downloads} />
|
||||||
count={p.installCount}
|
|
||||||
source={p.installCountSource}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -1148,10 +1145,7 @@ function BrowseCard({
|
|||||||
{plugin.author && <span>{plugin.author}</span>}
|
{plugin.author && <span>{plugin.author}</span>}
|
||||||
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
|
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
|
||||||
{plugin.category && <span>{plugin.category}</span>}
|
{plugin.category && <span>{plugin.category}</span>}
|
||||||
<InstallCountFact
|
<DownloadCount count={plugin.downloads} />
|
||||||
count={plugin.installCount}
|
|
||||||
source={plugin.installCountSource}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -126,31 +126,16 @@ export function BetaBadge() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Active installs when the registry has them, release downloads otherwise. */
|
/** How often the plugin's release files were downloaded. */
|
||||||
export function InstallCountFact({
|
export function DownloadCount({ count }: { count: number | null }) {
|
||||||
count,
|
|
||||||
source,
|
|
||||||
}: {
|
|
||||||
count: number | null;
|
|
||||||
source: string | null;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (count === null || count <= 0) return null;
|
if (count === null || count <= 0) return null;
|
||||||
const active = source === "aggregate-telemetry";
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span title={t("plugins.manager.count.downloadsHint")}>
|
||||||
title={
|
{t("plugins.manager.count.downloads", {
|
||||||
active
|
count,
|
||||||
? t("plugins.manager.count.activeHint")
|
formatted: formatCount(count),
|
||||||
: t("plugins.manager.count.downloadsHint")
|
})}
|
||||||
}
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
active
|
|
||||||
? "plugins.manager.count.active"
|
|
||||||
: "plugins.manager.count.downloads",
|
|
||||||
{ count, formatted: formatCount(count) },
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -62,8 +62,7 @@ export interface PluginEntry {
|
|||||||
inRegistry: boolean;
|
inRegistry: boolean;
|
||||||
contributes: Record<string, unknown> | null;
|
contributes: Record<string, unknown> | null;
|
||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
installCount: number | null;
|
downloads: number | null;
|
||||||
installCountSource: string | null;
|
|
||||||
source: PluginSource;
|
source: PluginSource;
|
||||||
/** Installed from a file or folder with no registry signature behind it. */
|
/** Installed from a file or folder with no registry signature behind it. */
|
||||||
unverified: boolean;
|
unverified: boolean;
|
||||||
@@ -161,8 +160,7 @@ export function mergePlugins(
|
|||||||
inRegistry: Boolean(entry),
|
inRegistry: Boolean(entry),
|
||||||
contributes: (summary.contributes as Record<string, unknown>) ?? null,
|
contributes: (summary.contributes as Record<string, unknown>) ?? null,
|
||||||
publishedAt: latestPublished(entry?.versions ?? []),
|
publishedAt: latestPublished(entry?.versions ?? []),
|
||||||
installCount: entry?.installCount ?? null,
|
downloads: entry?.downloads ?? null,
|
||||||
installCountSource: entry?.installCountSource ?? null,
|
|
||||||
source: pluginSource(summary),
|
source: pluginSource(summary),
|
||||||
unverified: pluginSource(summary) === "unverified",
|
unverified: pluginSource(summary) === "unverified",
|
||||||
registryId: entry?.registryId ?? summary.registryId ?? null,
|
registryId: entry?.registryId ?? summary.registryId ?? null,
|
||||||
@@ -205,8 +203,7 @@ export function mergePlugins(
|
|||||||
inRegistry: true,
|
inRegistry: true,
|
||||||
contributes: null,
|
contributes: null,
|
||||||
publishedAt: latestPublished(entry.versions),
|
publishedAt: latestPublished(entry.versions),
|
||||||
installCount: entry.installCount ?? null,
|
downloads: entry.downloads ?? null,
|
||||||
installCountSource: entry.installCountSource ?? null,
|
|
||||||
source: entry.tier ?? "official",
|
source: entry.tier ?? "official",
|
||||||
unverified: false,
|
unverified: false,
|
||||||
registryId: entry.registryId ?? "official",
|
registryId: entry.registryId ?? "official",
|
||||||
@@ -246,8 +243,7 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
|||||||
inRegistry: false,
|
inRegistry: false,
|
||||||
contributes: null,
|
contributes: null,
|
||||||
publishedAt: null,
|
publishedAt: null,
|
||||||
installCount: null,
|
downloads: null,
|
||||||
installCountSource: null,
|
|
||||||
source: "unverified",
|
source: "unverified",
|
||||||
unverified: true,
|
unverified: true,
|
||||||
registryId: null,
|
registryId: null,
|
||||||
@@ -270,7 +266,7 @@ export function sortPlugins(rows: PluginEntry[], key: SortKey): PluginEntry[] {
|
|||||||
if (key === "popular") {
|
if (key === "popular") {
|
||||||
sorted.sort(
|
sorted.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
(b.installCount ?? -1) - (a.installCount ?? -1) ||
|
(b.downloads ?? -1) - (a.downloads ?? -1) ||
|
||||||
a.name.localeCompare(b.name),
|
a.name.localeCompare(b.name),
|
||||||
);
|
);
|
||||||
} else if (key === "updated") {
|
} else if (key === "updated") {
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ const [plugin] = mergePlugins(
|
|||||||
pinnedVersion: null,
|
pinnedVersion: null,
|
||||||
autoUpdate: false,
|
autoUpdate: false,
|
||||||
bundled: false,
|
bundled: false,
|
||||||
installCount: null,
|
downloads: null,
|
||||||
installCountSource: null,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ const plugin = {
|
|||||||
capabilities: [],
|
capabilities: [],
|
||||||
contributes: {},
|
contributes: {},
|
||||||
versions: [],
|
versions: [],
|
||||||
installCount: null,
|
downloads: null,
|
||||||
installCountSource: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("@/plugins/use-plugins-manager", () => ({
|
vi.mock("@/plugins/use-plugins-manager", () => ({
|
||||||
|
|||||||
@@ -61,8 +61,7 @@ function entry(
|
|||||||
pinnedVersion: null,
|
pinnedVersion: null,
|
||||||
autoUpdate: false,
|
autoUpdate: false,
|
||||||
bundled: true,
|
bundled: true,
|
||||||
installCount: null,
|
downloads: null,
|
||||||
installCountSource: null,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -235,9 +234,9 @@ describe("popularity", () => {
|
|||||||
const rows = mergePlugins(
|
const rows = mergePlugins(
|
||||||
[],
|
[],
|
||||||
[
|
[
|
||||||
entry({ id: "a", name: "A", installed: false, installCount: 5 }),
|
entry({ id: "a", name: "A", installed: false, downloads: 5 }),
|
||||||
entry({ id: "b", name: "B", installed: false, installCount: null }),
|
entry({ id: "b", name: "B", installed: false, downloads: null }),
|
||||||
entry({ id: "c", name: "C", installed: false, installCount: 90 }),
|
entry({ id: "c", name: "C", installed: false, downloads: 90 }),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
expect(sortPlugins(rows, "popular").map((r) => r.id)).toEqual([
|
expect(sortPlugins(rows, "popular").map((r) => r.id)).toEqual([
|
||||||
|
|||||||
Reference in new issue
Block a user