Add GitHub plugin release imports and update discovery
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import PluginGithubImport from './PluginGithubImport.svelte';
|
||||
import type { PluginSource } from '$lib/plugin-github.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import Card from './primitives/Card.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
@@ -30,6 +32,7 @@
|
||||
interface Version {
|
||||
id: number; version: number; sha256: string; size: number;
|
||||
release_version: string | null;
|
||||
source: PluginSource | null;
|
||||
notes: string | null; state: 'untested' | 'tested' | 'test-failed';
|
||||
uploaded_at: string;
|
||||
}
|
||||
@@ -340,7 +343,9 @@
|
||||
<span class="v-num">{artifactVersionLabel(v)}</span>
|
||||
<span class="v-sha">{v.sha256.slice(0, 12)}</span>
|
||||
<span class="v-size">{v.size} B</span>
|
||||
<span class="v-notes">{v.notes ?? ''}</span>
|
||||
<span class="v-notes">{v.notes ?? ''}
|
||||
{#if v.source}<a href={v.source.release_url} target="_blank" rel="noreferrer">GitHub · {v.source.asset_name} ↗</a>{/if}
|
||||
</span>
|
||||
<span class="v-when">{v.uploaded_at}</span>
|
||||
{#if a.current_version_id === v.id}
|
||||
<Badge variant="online" size="sm">Promoted</Badge>
|
||||
@@ -353,6 +358,10 @@
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if family === 'plugin'}
|
||||
<div id="github-releases"><PluginGithubImport pluginId={id} targetPath={a.target_rel_path} disabled={runningBetaId !== null} onimport={() => load()} /></div>
|
||||
{/if}
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="panel-head"><h2>Danger zone</h2></div>
|
||||
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
|
||||
@@ -500,6 +509,8 @@
|
||||
.v-num { color: var(--text-primary); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
||||
.v-sha, .v-size, .v-when { color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
|
||||
.v-notes { color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-sm); }
|
||||
.v-notes { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 160px; overflow-y: auto; }
|
||||
.v-notes a { display: block; color: var(--accent-primary-hover); margin-top: var(--space-2); }
|
||||
|
||||
.alert {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { GithubPluginRelease, PluginUpdate } from '$lib/plugin-github.js';
|
||||
import Card from './primitives/Card.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
import Badge from './primitives/Badge.svelte';
|
||||
|
||||
let { pluginId, targetPath, disabled = false, onimport }: {
|
||||
pluginId?: number; targetPath?: string; disabled?: boolean; onimport?: () => void | Promise<void>;
|
||||
} = $props();
|
||||
let url = $state('');
|
||||
let name = $state('');
|
||||
let game = $state('');
|
||||
let release = $state<GithubPluginRelease | null>(null);
|
||||
let assetId = $state('');
|
||||
let update = $state<PluginUpdate | null>(null);
|
||||
let busy = $state(false);
|
||||
let checking = $state(false);
|
||||
let error = $state('');
|
||||
let success = $state<{ pluginId: number; betaUrl: string; sha256: string; alreadyImported: boolean } | null>(null);
|
||||
|
||||
function showRelease(value: GithubPluginRelease) {
|
||||
release = value;
|
||||
const preferred = update?.source?.asset_name ?? targetPath?.split('/').pop();
|
||||
assetId = String(value.assets.find(a => a.name === preferred)?.id ?? (value.assets.length === 1 ? value.assets[0].id : ''));
|
||||
if (!pluginId && !name) name = value.repository.split('/')[1];
|
||||
url = value.url;
|
||||
error = ''; success = null;
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
if (!pluginId || checking) return;
|
||||
checking = true; error = '';
|
||||
try {
|
||||
const response = await fetch(`/api/plugins/updates?pluginId=${pluginId}`);
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not check for updates.');
|
||||
update = body.updates[0];
|
||||
if (!url && update?.source) url = `https://github.com/${update.source.repository}`;
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { checking = false; }
|
||||
}
|
||||
onMount(() => { void checkUpdates(); });
|
||||
|
||||
async function preview(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || disabled) return;
|
||||
busy = true; error = ''; release = null; success = null;
|
||||
try {
|
||||
const response = await fetch(`/api/plugins/github/release?url=${encodeURIComponent(url.trim())}`);
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not load release.');
|
||||
showRelease(body.release);
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
|
||||
async function importRelease(startBeta: boolean) {
|
||||
if (!release || !assetId || busy || disabled) return;
|
||||
busy = true; error = ''; success = null;
|
||||
try {
|
||||
const response = await fetch('/api/plugins/github/import', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: release.url, assetId: Number(assetId), pluginId,
|
||||
name: name.trim(), scopeType: game ? 'game' : null, scopeValue: game || null })
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Import failed.');
|
||||
success = body;
|
||||
await onimport?.();
|
||||
if (startBeta) await goto(body.betaUrl);
|
||||
else if (pluginId) await checkUpdates();
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card padding="lg">
|
||||
<section class="github" aria-label="GitHub plugin releases">
|
||||
<div class="heading">
|
||||
<div><h2>{pluginId ? 'GitHub releases' : 'Import from GitHub'}</h2><p>Choose a release and DLL, then test it with your fleet.</p></div>
|
||||
{#if pluginId}<Button size="sm" onclick={checkUpdates} loading={checking} disabled={busy}>Check for updates</Button>{/if}
|
||||
</div>
|
||||
{#if checking}<p role="status">Checking GitHub…</p>{/if}
|
||||
{#if update?.source}
|
||||
<p>Following <a href={`https://github.com/${update.source.repository}`} target="_blank" rel="noreferrer">{update.source.repository}</a> · {update.source.asset_name}</p>
|
||||
{/if}
|
||||
{#if update?.status === 'available' && update.release}
|
||||
<div class="update">
|
||||
<Badge variant="info">New release: {update.release.tag}</Badge>
|
||||
<Button size="sm" disabled={busy || disabled} onclick={() => showRelease(update!.release!)}>Review update</Button>
|
||||
</div>
|
||||
{:else if update?.status === 'imported' && update.release}
|
||||
<div class="update"><span>{update.release.tag} is already imported and available for testing.</span>
|
||||
<Button size="sm" href={`/betas?pluginId=${pluginId}&versionId=${update.versionId}`} disabled={disabled}>Start beta</Button></div>
|
||||
{:else if update?.status === 'current'}
|
||||
<p role="status">The latest stable release is promoted.</p>
|
||||
{:else if update?.status === 'ahead'}
|
||||
<p role="status">Your imported release is newer than GitHub’s latest stable release.</p>
|
||||
{:else if update?.status === 'error'}
|
||||
<p class="error" role="alert">{update.error}</p>
|
||||
{:else if update?.status === 'untracked'}
|
||||
<p>Import a release below to track updates for this plugin.</p>
|
||||
{/if}
|
||||
<form onsubmit={preview} class="lookup">
|
||||
<label>Repository or release URL
|
||||
<input type="url" bind:value={url} oninput={() => { release = null; success = null; }} required
|
||||
placeholder="https://github.com/alicealys/t6-gsc-utils/releases/tag/v3.1.0" disabled={busy || disabled} />
|
||||
</label>
|
||||
<Button type="submit" loading={busy && !release} disabled={busy || disabled || !url.trim()}>Preview release</Button>
|
||||
</form>
|
||||
{#if disabled}<p>Finish the running beta before importing a release.</p>{/if}
|
||||
{#if release}
|
||||
<div class="preview">
|
||||
<div class="heading"><h3>{release.name || release.tag}</h3><Badge variant={release.prerelease ? 'warning' : 'neutral'}>{release.prerelease ? 'Prerelease' : 'Release'} · {release.tag}</Badge></div>
|
||||
<p><a href={release.url} target="_blank" rel="noreferrer">View on GitHub ↗</a> · Published {new Date(release.publishedAt).toLocaleDateString()}</p>
|
||||
<details open><summary>Release notes</summary><pre>{release.notes || 'No release notes provided.'}</pre></details>
|
||||
{#if release.assets.length === 0}
|
||||
<p>No importable DLL assets found. Choose another release or upload a DLL manually. Direct DLL assets up to 64 MB are supported.</p>
|
||||
{:else}
|
||||
<div class="fields">
|
||||
<label>DLL asset<select bind:value={assetId} disabled={busy || disabled}>
|
||||
<option value="" disabled>Choose a DLL…</option>
|
||||
{#each release.assets as asset}<option value={String(asset.id)}>{asset.name} · {(asset.size / 1024).toFixed(1)} KB</option>{/each}
|
||||
</select></label>
|
||||
{#if !pluginId}
|
||||
<label>Plugin name<input bind:value={name} disabled={busy} placeholder="t6-gsc-utils" /></label>
|
||||
<label>Deploy scope<select bind:value={game} disabled={busy}><option value="">Unscoped — select servers in a beta</option><option value="t5">T5 · Black Ops I</option><option value="t6">T6 · Black Ops II</option></select></label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if targetPath}<p>New versions deploy to <code>{targetPath}</code>.</p>{/if}
|
||||
<div class="actions">
|
||||
<Button variant="primary" disabled={busy || disabled || !assetId || (!pluginId && !name.trim())} loading={busy} onclick={() => importRelease(true)}>Import and test</Button>
|
||||
<Button disabled={busy || disabled || !assetId || (!pluginId && !name.trim())} onclick={() => importRelease(false)}>Import only</Button>
|
||||
</div>
|
||||
<p>Imports are saved for testing. Choose servers and a test plan before starting a beta.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="success" role="status">
|
||||
<strong>{success.alreadyImported ? 'This binary is already imported.' : 'Release imported.'}</strong>
|
||||
<a href={`/plugins/${success.pluginId}`}>View plugin</a> · <a href={success.betaUrl}>Start beta</a>
|
||||
<span class="checksum">SHA-256: {success.sha256}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.github, .preview { display: grid; gap: var(--space-4); }
|
||||
.heading, .actions, .update { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.actions { justify-content: flex-start; }
|
||||
h2, h3, p { margin: 0; }
|
||||
h2 { font-size: var(--text-md); font-weight: var(--weight-semibold); }
|
||||
h3 { font-size: var(--text-md); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; }
|
||||
.heading p { margin-top: var(--space-2); }
|
||||
.lookup { display: flex; align-items: flex-end; gap: var(--space-3); flex-wrap: wrap; }
|
||||
.lookup label { flex: 1 1 300px; }
|
||||
label { display: grid; gap: var(--space-2); font-size: var(--text-sm); }
|
||||
input, select { width: 100%; min-width: 0; padding: 10px 12px; background: var(--surface-sunken); border: 1px solid var(--border-strong); border-radius: var(--radius-md); color: var(--text-primary); font: inherit; }
|
||||
input:focus-visible, select:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.fields { display: grid; gap: var(--space-3); grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); }
|
||||
.preview { padding-top: var(--space-4); border-top: 1px solid var(--border-subtle); }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 280px; overflow-y: auto; font: inherit; font-size: var(--text-sm); color: var(--text-secondary); line-height: 1.6; }
|
||||
summary { cursor: pointer; font-size: var(--text-sm); }
|
||||
.error { color: var(--state-danger); }
|
||||
.success { display: block; padding: var(--space-3); background: var(--surface-sunken); border-radius: var(--radius-md); font-size: var(--text-sm); }
|
||||
.success strong, .checksum { display: block; margin-bottom: var(--space-2); }
|
||||
.checksum { overflow-wrap: anywhere; font: var(--text-xs) var(--font-mono); margin-top: var(--space-2); }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { PluginUpdate } from '$lib/plugin-github.js';
|
||||
import Card from './primitives/Card.svelte';
|
||||
import Button from './primitives/Button.svelte';
|
||||
let updates = $state<PluginUpdate[]>([]);
|
||||
let checking = $state(false);
|
||||
let checked = $state(false);
|
||||
let error = $state('');
|
||||
const tracked = $derived(updates.filter(u => u.status !== 'untracked'));
|
||||
async function check() {
|
||||
if (checking) return;
|
||||
checking = true; error = '';
|
||||
try {
|
||||
const response = await fetch('/api/plugins/updates');
|
||||
const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error ?? 'Could not check updates.');
|
||||
updates = body.updates; checked = true;
|
||||
} catch (e) { error = (e as Error).message; }
|
||||
finally { checking = false; }
|
||||
}
|
||||
onMount(() => { void check(); });
|
||||
</script>
|
||||
|
||||
<Card padding="lg">
|
||||
<div class="heading"><h2>Release updates</h2><Button size="sm" loading={checking} onclick={check}>Check for updates</Button></div>
|
||||
<p>Latest stable GitHub releases. Checks are cached for five minutes.</p>
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
{#if checked && !tracked.length}<p>Import a plugin from GitHub to start tracking its releases.</p>{/if}
|
||||
{#each tracked as update (update.pluginId)}
|
||||
<div class="row">
|
||||
<div><a href={`/plugins/${update.pluginId}`}>{update.name}</a>
|
||||
<p>{update.status === 'available' ? `${update.release?.tag} available` : update.status === 'imported' ? `${update.release?.tag} imported — awaiting promotion` : update.status === 'current' ? `${update.release?.tag} promoted` : update.status === 'ahead' ? 'Ahead of the latest stable release' : update.error}</p>
|
||||
</div>
|
||||
{#if update.status === 'available'}<Button size="sm" href={`/plugins/${update.pluginId}#github-releases`}>Review update</Button>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.heading, .row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
|
||||
h2 { margin: 0; font-size: var(--text-md); font-weight: var(--weight-semibold); }
|
||||
p { color: var(--text-secondary); font-size: var(--text-sm); margin: var(--space-2) 0 0; }
|
||||
.row { padding-top: var(--space-3); margin-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.error { color: var(--state-danger); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user