feat: Add rotating proxy with health check, fix CORS for localhost, update extension
This commit is contained in:
parent
24aa18054c
commit
7da10fb2c3
10 changed files with 290 additions and 38 deletions
4
bun.lock
4
bun.lock
|
|
@ -19,7 +19,7 @@
|
|||
"@svta/common-media-library": "^0.18.1",
|
||||
"@types/wicg-file-system-access": "^2023.10.7",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
||||
"@uimaxbai/am-lyrics": "^1.2.8",
|
||||
"@uimaxbai/am-lyrics": "^1.2.9",
|
||||
"@vitest/web-worker": "^4.1.2",
|
||||
"appwrite": "^23.0.0",
|
||||
"butterchurn": "^2.6.7",
|
||||
|
|
@ -678,7 +678,7 @@
|
|||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="],
|
||||
|
||||
"@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.2.8", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-aR8kxqIYcVlsMCH6bbH8ANG+bN/2OAw66ZFjYD1a25hkMTyxtULWgWwAZlUfreP9V47bFvNgXIKvOqhO5JFpeg=="],
|
||||
"@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-0IpvmCY6384cIKntIap/N9Rq/JuacSjL7Dq+QXPqRAjewZVVGcxEgka7tXtm1BnhwqIZWYWT/2Uzz6aA71RSmQ=="],
|
||||
|
||||
"@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="],
|
||||
|
||||
|
|
|
|||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
monochrome:
|
||||
build: .
|
||||
ports:
|
||||
- '8080:4173'
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data:/data # For local storage if needed
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
|
@ -31,7 +31,15 @@
|
|||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["*://monochrome.samidy.com/*", "*://monochrome.tf/*", "*://lossless.wtf/*", "*://localhost/*"],
|
||||
"matches": [
|
||||
"*://monochrome.samidy.com/*",
|
||||
"*://monochrome.tf/*",
|
||||
"*://lossless.wtf/*",
|
||||
"*://localhost:5173/*",
|
||||
"*://localhost:5174/*",
|
||||
"*://localhost:5175/*",
|
||||
"*://localhost:5176/*"
|
||||
],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_start",
|
||||
"all_frames": true
|
||||
|
|
@ -45,7 +53,15 @@
|
|||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["inject.js"],
|
||||
"matches": ["*://monochrome.samidy.com/*", "*://monochrome.tf/*", "*://lossless.wtf/*", "*://localhost/*"]
|
||||
"matches": [
|
||||
"*://monochrome.samidy.com/*",
|
||||
"*://monochrome.tf/*",
|
||||
"*://lossless.wtf/*",
|
||||
"*://localhost:5173/*",
|
||||
"*://localhost:5174/*",
|
||||
"*://localhost:5175/*",
|
||||
"*://localhost:5176/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
},
|
||||
"condition": {
|
||||
"urlFilter": "||tidal.com*",
|
||||
"initiatorDomains": ["monochrome.tf", "monochrome.samidy.com"],
|
||||
"initiatorDomains": ["monochrome.tf", "monochrome.samidy.com", "localhost"],
|
||||
"resourceTypes": ["xmlhttprequest", "media"]
|
||||
}
|
||||
},
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
},
|
||||
"condition": {
|
||||
"urlFilter": "||tidal.com*",
|
||||
"initiatorDomains": ["monochrome.tf", "monochrome.samidy.com"],
|
||||
"initiatorDomains": ["monochrome.tf", "monochrome.samidy.com", "localhost"],
|
||||
"resourceTypes": ["xmlhttprequest", "media"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
js/api.js
10
js/api.js
|
|
@ -21,6 +21,7 @@ import { resolveDownloadTotalBytes } from './downloadProgressUtils.js';
|
|||
import { readableStreamIterator } from './readableStreamIterator.js';
|
||||
import { HiFiClient, TidalResponse } from './HiFi.ts';
|
||||
import { isIos, isSafari, isChrome } from './platform-detection.js';
|
||||
import { instanceHealth } from './instance-health.js';
|
||||
import {
|
||||
TrackAlbum,
|
||||
EnrichedAlbum,
|
||||
|
|
@ -112,9 +113,9 @@ export class LosslessAPI {
|
|||
};
|
||||
|
||||
const tryInstances = async (instances) => {
|
||||
const maxTotalAttempts = instances.length * 2; // Allow some retries across instances
|
||||
const maxTotalAttempts = instances.length * 2;
|
||||
let lastError = null;
|
||||
let instanceIndex = Math.floor(Math.random() * instances.length);
|
||||
let instanceIndex = 0;
|
||||
|
||||
for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) {
|
||||
const instance = instances[instanceIndex % instances.length];
|
||||
|
|
@ -128,12 +129,14 @@ export class LosslessAPI {
|
|||
|
||||
if (response.status === 429) {
|
||||
console.warn(`Rate limit hit on ${baseUrl}. Trying next instance...`);
|
||||
if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type);
|
||||
instanceIndex++;
|
||||
await delay(500);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +147,7 @@ export class LosslessAPI {
|
|||
.catch(() => null);
|
||||
if (errorData?.subStatus === 11002) {
|
||||
console.warn(`Auth failed on ${baseUrl}. Trying next instance...`);
|
||||
if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type);
|
||||
instanceIndex++;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -151,6 +155,7 @@ export class LosslessAPI {
|
|||
|
||||
if (response.status >= 500) {
|
||||
console.warn(`Server error ${response.status} on ${baseUrl}. Trying next instance...`);
|
||||
if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type);
|
||||
instanceIndex++;
|
||||
continue;
|
||||
}
|
||||
|
|
@ -161,6 +166,7 @@ export class LosslessAPI {
|
|||
if (error.name === 'AbortError') throw error;
|
||||
lastError = error;
|
||||
console.warn(`Network error on ${baseUrl}: ${error.message}. Trying next instance...`);
|
||||
if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type);
|
||||
instanceIndex++;
|
||||
await delay(200);
|
||||
}
|
||||
|
|
|
|||
162
js/instance-health.js
Normal file
162
js/instance-health.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
//instance-health.js
|
||||
|
||||
const HEALTH_CHECK_INTERVAL = 5 * 60 * 1000;
|
||||
const HEALTH_CHECK_TIMEOUT = 8000;
|
||||
const MAX_CONSECUTIVE_FAILURES = 3;
|
||||
const INITIAL_DELAY = 10000;
|
||||
const IS_DEV = typeof window !== 'undefined' && window.location?.hostname === 'localhost';
|
||||
|
||||
class InstanceHealthChecker {
|
||||
constructor() {
|
||||
this.healthCache = new Map();
|
||||
this.checkTimers = {};
|
||||
this.listeners = [];
|
||||
}
|
||||
|
||||
onHealthUpdate(callback) {
|
||||
this.listeners.push(callback);
|
||||
}
|
||||
|
||||
notifyListeners(instances) {
|
||||
this.listeners.forEach((cb) => cb(instances));
|
||||
}
|
||||
|
||||
async checkInstance(url, type = 'api') {
|
||||
const testPath = '/';
|
||||
const fullUrl = url.endsWith('/') ? `${url}${testPath.substring(1)}` : `${url}${testPath}`;
|
||||
|
||||
const startTime = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
const existing = this.healthCache.get(url) || { url, failures: 0 };
|
||||
const status = response.ok || response.status === 401 || response.status === 404 ? 'online' : 'degraded';
|
||||
|
||||
this.healthCache.set(url, {
|
||||
...existing,
|
||||
status,
|
||||
latency,
|
||||
lastCheck: Date.now(),
|
||||
failures: response.ok ? 0 : existing.failures,
|
||||
type,
|
||||
});
|
||||
|
||||
return { url, status, latency, online: response.ok };
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
const existing = this.healthCache.get(url) || { url, failures: 0 };
|
||||
const newFailures = existing.failures + 1;
|
||||
const status = newFailures >= MAX_CONSECUTIVE_FAILURES ? 'offline' : 'degraded';
|
||||
|
||||
this.healthCache.set(url, {
|
||||
url,
|
||||
status,
|
||||
latency: null,
|
||||
lastCheck: Date.now(),
|
||||
failures: newFailures,
|
||||
error: error.message,
|
||||
type,
|
||||
});
|
||||
|
||||
return { url, status, latency: null, online: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async checkAll(instances, type = 'api') {
|
||||
const checks = instances.map((inst) => {
|
||||
const url = typeof inst === 'string' ? inst : inst.url;
|
||||
return this.checkInstance(url, type);
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(checks);
|
||||
return results.map((r) => r.value || { url: 'unknown', status: 'offline', online: false });
|
||||
}
|
||||
|
||||
getHealthStatus(url) {
|
||||
return this.healthCache.get(url) || { url, status: 'unknown', latency: null, lastCheck: null };
|
||||
}
|
||||
|
||||
getHealthyInstances(instances, type = 'api') {
|
||||
return instances.filter((inst) => {
|
||||
const url = typeof inst === 'string' ? inst : inst.url;
|
||||
const health = this.healthCache.get(url);
|
||||
return !health || health.status === 'online' || health.status === 'degraded';
|
||||
});
|
||||
}
|
||||
|
||||
getHealthyAndSorted(instances, type = 'api') {
|
||||
const withHealth = instances.map((inst) => {
|
||||
const url = typeof inst === 'string' ? inst : inst.url;
|
||||
const health = this.healthCache.get(url);
|
||||
return {
|
||||
...inst,
|
||||
healthStatus: health?.status || 'unknown',
|
||||
latency: health?.latency ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return withHealth.sort((a, b) => {
|
||||
if (a.healthStatus === 'offline' && b.healthStatus !== 'offline') return 1;
|
||||
if (b.healthStatus === 'offline' && a.healthStatus !== 'offline') return -1;
|
||||
if (a.latency !== null && b.latency !== null) return a.latency - b.latency;
|
||||
if (a.latency !== null) return -1;
|
||||
if (b.latency !== null) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
startPeriodicCheck(instancesGetter, type = 'api') {
|
||||
if (this.checkTimers[type]) {
|
||||
clearInterval(this.checkTimers[type]);
|
||||
}
|
||||
|
||||
if (IS_DEV) {
|
||||
console.log(`Health check disabled in dev mode for ${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const check = async () => {
|
||||
const instances = await instancesGetter(type);
|
||||
if (!instances || instances.length === 0) return;
|
||||
const results = await this.checkAll(instances, type);
|
||||
this.notifyListeners(results);
|
||||
};
|
||||
|
||||
setTimeout(check, INITIAL_DELAY);
|
||||
this.checkTimers[type] = setInterval(check, HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
stopPeriodicCheck(type = 'api') {
|
||||
if (this.checkTimers[type]) {
|
||||
clearInterval(this.checkTimers[type]);
|
||||
delete this.checkTimers[type];
|
||||
}
|
||||
}
|
||||
|
||||
stopAllChecks() {
|
||||
Object.keys(this.checkTimers).forEach((type) => this.stopPeriodicCheck(type));
|
||||
}
|
||||
|
||||
async quickCheck(url, type = 'api') {
|
||||
return this.checkInstance(url, type);
|
||||
}
|
||||
|
||||
getCachedStatus(url) {
|
||||
return this.healthCache.get(url);
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.healthCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const instanceHealth = new InstanceHealthChecker();
|
||||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import { LosslessAPI } from './api.js';
|
||||
import { PodcastsAPI } from './podcasts-api.js';
|
||||
import { musicProviderSettings } from './storage.js';
|
||||
import { musicProviderSettings, apiSettings } from './storage.js';
|
||||
import { instanceHealth } from './instance-health.js';
|
||||
|
||||
/**
|
||||
* MusicAPI - Singleton class that provides a unified interface for accessing music streaming services.
|
||||
|
|
@ -64,7 +65,14 @@ export class MusicAPI {
|
|||
}
|
||||
|
||||
const api = new MusicAPI(settings);
|
||||
return (MusicAPI.#instance = api);
|
||||
MusicAPI.#instance = api;
|
||||
|
||||
if (!window.location?.hostname?.includes('localhost')) {
|
||||
instanceHealth.startPeriodicCheck((type) => apiSettings.getInstances(type), 'api');
|
||||
instanceHealth.startPeriodicCheck((type) => apiSettings.getInstances(type), 'streaming');
|
||||
}
|
||||
|
||||
return MusicAPI.#instance;
|
||||
}
|
||||
|
||||
getCurrentProvider() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
const PROXIES = [
|
||||
{ url: 'https://audio-proxy.binimum.org/proxy-audio', param: 'url=' },
|
||||
{ url: 'https://corsproxy.io/?', param: '' },
|
||||
];
|
||||
|
||||
let proxyIndex = 0;
|
||||
|
||||
export const getProxyUrl = (url) => {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
if (window.__tidalOriginExtension) return url;
|
||||
if (url.startsWith('blob:')) return url;
|
||||
if (url.startsWith('https://audio-proxy.binimum.org/')) return url;
|
||||
return `https://audio-proxy.binimum.org/proxy-audio?url=${url}`;
|
||||
|
||||
const proxy = PROXIES[proxyIndex % PROXIES.length];
|
||||
return `${proxy.url}${proxy.param}${encodeURIComponent(url)}`;
|
||||
};
|
||||
|
||||
export const rotateProxy = () => {
|
||||
proxyIndex = (proxyIndex + 1) % PROXIES.length;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,9 +4,38 @@ import { SVG_RIGHT_ARROW } from './icons';
|
|||
|
||||
export const apiSettings = {
|
||||
STORAGE_KEY: 'monochrome-api-instances-v9',
|
||||
INSTANCES_URLS: [
|
||||
'https://tidal-uptime.geeked.wtf',
|
||||
INSTANCES_URLS: ['https://tidal-uptime.geeked.wtf'],
|
||||
FALLBACK_INSTANCES: {
|
||||
api: [
|
||||
{ url: 'https://eu-central.monochrome.tf', version: '2.10' },
|
||||
{ url: 'https://us-west.monochrome.tf', version: '2.10' },
|
||||
{ url: 'https://api.monochrome.tf', version: '2.5' },
|
||||
{ url: 'https://arran.monochrome.tf', version: '2.6' },
|
||||
{ url: 'https://tidal.kinoplus.online', version: '2.2' },
|
||||
{ url: 'https://tidal-api.binimum.org', version: '2.6' },
|
||||
{ url: 'https://wolf.qqdl.site', version: '2.2' },
|
||||
{ url: 'https://maus.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://vogel.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://katze.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://hund.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://hifi-one.spotisaver.net', version: '2.5' },
|
||||
{ url: 'https://hifi-two.spotisaver.net', version: '2.5' },
|
||||
],
|
||||
streaming: [
|
||||
{ url: 'https://eu-central.monochrome.tf', version: '2.10' },
|
||||
{ url: 'https://us-west.monochrome.tf', version: '2.10' },
|
||||
{ url: 'https://arran.monochrome.tf', version: '2.6' },
|
||||
{ url: 'https://tidal.kinoplus.online', version: '2.2' },
|
||||
{ url: 'https://tidal-api.binimum.org', version: '2.6' },
|
||||
{ url: 'https://wolf.qqdl.site', version: '2.2' },
|
||||
{ url: 'https://maus.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://vogel.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://katze.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://hund.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://hifi-one.spotisaver.net', version: '2.5' },
|
||||
{ url: 'https://hifi-two.spotisaver.net', version: '2.5' },
|
||||
],
|
||||
},
|
||||
defaultInstances: { api: [], streaming: [] },
|
||||
userInstances: null,
|
||||
instancesLoaded: false,
|
||||
|
|
@ -75,27 +104,8 @@ export const apiSettings = {
|
|||
if (!data) {
|
||||
console.error('Failed to load instances from all uptime APIs:', fetchError);
|
||||
this.defaultInstances = {
|
||||
api: [
|
||||
{ url: 'https://hifi.geeked.wtf', version: '2.7' },
|
||||
{ url: 'https://eu-central.monochrome.tf', version: '2.7' },
|
||||
{ url: 'https://us-west.monochrome.tf', version: '2.7' },
|
||||
{ url: 'https://api.monochrome.tf', version: '2.5' },
|
||||
{ url: 'https://monochrome-api.samidy.com', version: '2.3' },
|
||||
{ url: 'https://maus.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://vogel.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://katze.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://hund.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://tidal.kinoplus.online', version: '2.2' },
|
||||
{ url: 'https://wolf.qqdl.site', version: '2.2' },
|
||||
],
|
||||
streaming: [
|
||||
{ url: 'https://hifi.geeked.wtf', version: '2.7' },
|
||||
{ url: 'https://maus.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://vogel.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://katze.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://hund.qqdl.site', version: '2.6' },
|
||||
{ url: 'https://wolf.qqdl.site', version: '2.6' },
|
||||
],
|
||||
api: [...this.FALLBACK_INSTANCES.api],
|
||||
streaming: [...this.FALLBACK_INSTANCES.streaming],
|
||||
};
|
||||
this.instancesLoaded = true;
|
||||
this._loadPromise = null;
|
||||
|
|
@ -113,12 +123,20 @@ export const apiSettings = {
|
|||
groupedInstances.api = data.api.filter((item) => !isBlockedInstance(item));
|
||||
}
|
||||
|
||||
if (data.streaming && Array.isArray(data.streaming)) {
|
||||
if (data.streaming && Array.isArray(data.streaming) && data.streaming.length > 0) {
|
||||
groupedInstances.streaming = data.streaming.filter((item) => !isBlockedInstance(item));
|
||||
} else if (groupedInstances.api.length > 0) {
|
||||
groupedInstances.streaming = [...groupedInstances.api];
|
||||
}
|
||||
|
||||
if (groupedInstances.streaming.length === 0) {
|
||||
groupedInstances.streaming = [...this.FALLBACK_INSTANCES.streaming];
|
||||
}
|
||||
|
||||
if (groupedInstances.api.length === 0) {
|
||||
groupedInstances.api = [...this.FALLBACK_INSTANCES.api];
|
||||
}
|
||||
|
||||
this.defaultInstances = groupedInstances;
|
||||
this.instancesLoaded = true;
|
||||
|
||||
|
|
|
|||
21
js/ui.js
21
js/ui.js
|
|
@ -31,6 +31,7 @@ import {
|
|||
fullscreenCoverNoRoundSettings,
|
||||
artistBannerSettings,
|
||||
} from './storage.js';
|
||||
import { instanceHealth } from './instance-health.js';
|
||||
import { db } from './db.js';
|
||||
import { getVibrantColorFromImage } from './vibrant-color.js';
|
||||
import { syncManager } from './accounts/pocketbase.js';
|
||||
|
|
@ -6103,12 +6104,28 @@ export class UIRenderer {
|
|||
const safeUrl = escapeHtml(instanceUrl || '');
|
||||
const safeVersion = escapeHtml(instanceVersion);
|
||||
|
||||
const health = instanceHealth.getCachedStatus(instanceUrl);
|
||||
const statusColor =
|
||||
health?.status === 'online'
|
||||
? '#10b981'
|
||||
: health?.status === 'degraded'
|
||||
? '#f59e0b'
|
||||
: health?.status === 'offline'
|
||||
? '#ef4444'
|
||||
: '#6b7280';
|
||||
const latencyDisplay = health?.latency ? `${health.latency}ms` : '';
|
||||
|
||||
return `
|
||||
<li data-index="${index}" data-type="${type}" data-url="${safeUrl}">
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div class="instance-url">${safeName} ${isUser ? '<span style="font-size: 0.6rem; opacity: 0.7; background: var(--muted); padding: 1px 4px; border-radius: 3px; margin-left: 4px; vertical-align: middle;">U</span>' : ''}</div>
|
||||
<div class="instance-url">
|
||||
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${statusColor}; margin-right: 6px; vertical-align: middle;"></span>
|
||||
${safeName} ${isUser ? '<span style="font-size: 0.6rem; opacity: 0.7; background: var(--muted); padding: 1px 4px; border-radius: 3px; margin-left: 4px; vertical-align: middle;">U</span>' : ''}
|
||||
</div>
|
||||
${safeUrl && safeUrl !== safeName ? `<div style="font-size: 0.8rem; color: var(--muted-foreground); margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${safeUrl}</div>` : ''}
|
||||
${safeVersion ? `<div style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.1rem;">v${safeVersion}</div>` : ''}
|
||||
<div style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.1rem;">
|
||||
${safeVersion ? `v${safeVersion}` : ''}${safeVersion && latencyDisplay ? ' · ' : ''}${latencyDisplay}
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
${
|
||||
|
|
|
|||
Loading…
Reference in a new issue