internal cleanups and build config
This commit is contained in:
10 files changed
+435
-22
No files matched your search
@@ -82,6 +82,9 @@
|
||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||
let currentEnvId = $state<number | null>(null);
|
||||
let lastUpdated = $state<Date>(new Date());
|
||||
// Live wall clock: ticks every second so the menubar time stays current between
|
||||
// data refreshes (lastUpdated only moves when data is fetched) (#1182).
|
||||
let now = $state<Date>(new Date());
|
||||
let isConnected = $state(false);
|
||||
let initializedFromStore = false;
|
||||
let switchingEnvId = $state<number | null>(null); // Track which env is being switched to
|
||||
@@ -341,7 +344,7 @@
|
||||
|| getDefaultTimezone()
|
||||
);
|
||||
|
||||
function formatLastUpdated(date: Date, timezone: string): string {
|
||||
function formatClock(date: Date, timezone: string): string {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
@@ -365,8 +368,10 @@
|
||||
fetchDiskUsage();
|
||||
// No polling - only fetch on mount and environment switch
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
const clockTimer = setInterval(() => { now = new Date(); }, 1000);
|
||||
return () => {
|
||||
abortPendingRequests(); // Abort on destroy
|
||||
clearInterval(clockTimer);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
};
|
||||
});
|
||||
@@ -541,7 +546,10 @@
|
||||
class="flex items-center gap-2 {isConnected ? 'text-emerald-500' : 'text-muted-foreground'}"
|
||||
title={isConnected ? 'Live updates connected' : 'Live updates disconnected'}
|
||||
>
|
||||
<span class="text-muted-foreground" title={currentTimezone}>{formatLastUpdated(lastUpdated, currentTimezone)}</span>
|
||||
<span
|
||||
class="text-muted-foreground tabular-nums"
|
||||
title={`${currentTimezone} - data refreshed at ${formatClock(lastUpdated, currentTimezone)}`}
|
||||
>{formatClock(now, currentTimezone)}</span>
|
||||
{#if isConnected}
|
||||
<Wifi class="{iconSizeLargeClass()}" />
|
||||
<span class="font-medium">Live</span>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* "Details" links for Lissy93 portainer-templates (#1211).
|
||||
*
|
||||
* The community site portainer-templates.as93.net renders a rich per-template page
|
||||
* for the Lissy93 catalog, but its URL slug is NOT a plain kebab of the title (it
|
||||
* strips ALL non-alphanumerics inside a word: `Pi-Hole` -> `pihole`,
|
||||
* `Pi-Hole-Unbound` -> `piholeunbound`). We can GUESS a slug that matches ~96% of
|
||||
* titles, so to avoid dead links we treat the site's own sitemap as the source of
|
||||
* truth: emit a Details link ONLY when the guessed slug actually exists there.
|
||||
* The 4% we can't match simply get no Details link (never a wrong one).
|
||||
*/
|
||||
|
||||
const SITEMAP_URL = 'https://portainer-templates.as93.net/sitemap.xml';
|
||||
const BASE_URL = 'https://portainer-templates.as93.net';
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000; // slugs change rarely; refresh daily
|
||||
|
||||
let cache: { slugs: Set<string>; fetchedAt: number } | null = null;
|
||||
let inflight: Promise<Set<string>> | null = null;
|
||||
|
||||
/** Test-only: clear the in-memory slug cache so a test starts from a cold state. */
|
||||
export function __resetAs93Cache(): void {
|
||||
cache = null;
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the candidate slug the way as93.net does: split on whitespace, strip every
|
||||
* non-alphanumeric from each word, lowercase, join with '-'. Pure + unit-tested.
|
||||
* This is only a CANDIDATE - it must be confirmed against the sitemap before use.
|
||||
*/
|
||||
export function slugifyTitle(title: string): string {
|
||||
return title
|
||||
.split(/\s+/)
|
||||
.map((w) => w.toLowerCase().replace(/[^a-z0-9]/g, ''))
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
}
|
||||
|
||||
/** Parse `<loc>https://portainer-templates.as93.net/<slug></loc>` entries into a Set. */
|
||||
export function parseSitemapSlugs(xml: string): Set<string> {
|
||||
const slugs = new Set<string>();
|
||||
for (const m of xml.matchAll(/portainer-templates\.as93\.net\/([a-z0-9-]+)/g)) {
|
||||
if (m[1]) slugs.add(m[1]);
|
||||
}
|
||||
return slugs;
|
||||
}
|
||||
|
||||
/** Fetch + cache the sitemap slug set (in-memory, 24h). Never throws; empty on failure. */
|
||||
export async function getAs93Slugs(fetchImpl: typeof fetch = fetch): Promise<Set<string>> {
|
||||
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL) return cache.slugs;
|
||||
if (inflight) return inflight;
|
||||
|
||||
inflight = (async () => {
|
||||
try {
|
||||
const res = await fetchImpl(SITEMAP_URL, {
|
||||
headers: { Accept: 'application/xml' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(15000)
|
||||
});
|
||||
if (!res.ok) return cache?.slugs ?? new Set<string>();
|
||||
const slugs = parseSitemapSlugs(await res.text());
|
||||
// Only replace the cache on a non-empty parse - a transient bad body must
|
||||
// not wipe a good set and drop every Details link.
|
||||
if (slugs.size > 0) cache = { slugs, fetchedAt: Date.now() };
|
||||
return cache?.slugs ?? slugs;
|
||||
} catch {
|
||||
return cache?.slugs ?? new Set<string>();
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
})();
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/**
|
||||
* The as93.net details URL for a template title, or null when the guessed slug is
|
||||
* not in the sitemap (so we never emit a dead link). Pure lookup against a slug set
|
||||
* the caller loaded once via getAs93Slugs.
|
||||
*/
|
||||
export function resolveAs93Url(title: string, slugs: Set<string>): string | null {
|
||||
const slug = slugifyTitle(title);
|
||||
if (!slug || !slugs.has(slug)) return null;
|
||||
return `${BASE_URL}/${slug}`;
|
||||
}
|
||||
@@ -33,6 +33,16 @@ export function isValidSelfhstRef(ref: string): boolean {
|
||||
return /^[a-z0-9-]{1,64}$/.test(ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean a client-supplied ref list for the batch endpoint: keep only valid string
|
||||
* refs, de-dupe, and cap the count so one request can't fan out unboundedly. Pure.
|
||||
*/
|
||||
export function sanitizeRefList(refs: unknown, max: number): string[] {
|
||||
if (!Array.isArray(refs)) return [];
|
||||
const valid = refs.filter((r): r is string => typeof r === 'string' && isValidSelfhstRef(r));
|
||||
return Array.from(new Set(valid)).slice(0, max);
|
||||
}
|
||||
|
||||
function cacheDir(): string {
|
||||
const dataDir = process.env.DATA_DIR || './data';
|
||||
const dir = resolve(dataDir, 'icon-cache', 'selfhst');
|
||||
|
||||
Reference in new issue
Block a user