diff --git a/src/modules/platform-tools/docker/docker.service.ts b/src/modules/platform-tools/docker/docker.service.ts index bbde8e475..6ba45b487 100644 --- a/src/modules/platform-tools/docker/docker.service.ts +++ b/src/modules/platform-tools/docker/docker.service.ts @@ -11,14 +11,19 @@ export class DockerService { constructor( private readonly configService: ConfigService, private readonly logger: Logger, - ) {} + ) { } /** * Returns the docker startup.sh script */ async getStartupScript() { - const script = await readFile(this.configService.startupScript, 'utf-8') - return { script } + try { + const script = await readFile(this.configService.startupScript, 'utf-8') + return { script } + } catch (error) { + this.logger.error('Error reading startup script:', error) + throw new Error('Could not read the startup script.') + } } /** diff --git a/src/modules/status/status.gateway.ts b/src/modules/status/status.gateway.ts index 89b511d58..6adeaea24 100644 --- a/src/modules/status/status.gateway.ts +++ b/src/modules/status/status.gateway.ts @@ -18,7 +18,7 @@ export class StatusGateway { constructor( private statusService: StatusService, private pluginsService: PluginsService, - ) {} + ) { } @SubscribeMessage('get-dashboard-layout') async getDashboardLayout() { @@ -65,6 +65,15 @@ export class StatusGateway { } } + @SubscribeMessage('docker-version-check') + async dockerVersionCheck() { + try { + return await this.statusService.getDockerDetails() + } catch (e) { + return new WsException(e.message) + } + } + @SubscribeMessage('nodejs-version-check') async nodeJsVersionCheck() { try { diff --git a/src/modules/status/status.service.ts b/src/modules/status/status.service.ts index bd41572f2..6d1815572 100644 --- a/src/modules/status/status.service.ts +++ b/src/modules/status/status.service.ts @@ -46,6 +46,22 @@ export interface HomebridgeStatusUpdate { pin?: string } +interface DockerRelease { + tag_name: string + published_at: string + prerelease: boolean + body: string +} + +interface DockerReleaseInfo { + version: string + publishedAt: string + isPrerelease: boolean + isTest: boolean + testTag: 'beta' | 'test' | null + isLatestStable: boolean +} + const execAsync = promisify(exec) @Injectable() @@ -592,4 +608,140 @@ export class StatusService { return output } + + /** + * Fetches Docker package details, including version information, release body, and system details. + * Accounts for version tag formats: YYYY-MM-DD (stable), beta-YYYY-MM-DD or test-YYYY-MM-DD (test). + * If currentVersion is beta/test, latestVersion is the latest beta/test version; otherwise, it's the latest stable. + * @returns A promise resolving to the Docker details object. + */ + public async getDockerDetails() { + const currentVersion = process.env.DOCKER_HOMEBRIDGE_VERSION + let latestVersion: string | null = null + let latestReleaseBody = '' + let updateAvailable = false + + try { + const { releases, rawReleases } = await this.getRecentReleases() + + // Determine the type of currentVersion and select the appropriate latest version + if (currentVersion) { + const lowerCurrentVersion = currentVersion.toLowerCase() + let targetReleases: DockerReleaseInfo[] = [] + + if (lowerCurrentVersion.startsWith('beta-')) { + // Current version is beta; select latest beta version + targetReleases = releases + .filter(release => release.testTag === 'beta' && /^beta-\d{4}-\d{2}-\d{2}$/i.test(release.version)) + .sort((a, b) => b.version.localeCompare(a.version)) // Sort by date descending + latestVersion = targetReleases[0]?.version || null + } else if (lowerCurrentVersion.startsWith('test-')) { + // Current version is test; select latest test version + targetReleases = releases + .filter(release => release.testTag === 'test' && /^test-\d{4}-\d{2}-\d{2}$/i.test(release.version)) + .sort((a, b) => b.version.localeCompare(a.version)) // Sort by date descending + latestVersion = targetReleases[0]?.version || null + } else { + // Current version is stable or invalid; select latest stable version + const stableRelease = releases.find(release => release.isLatestStable) + latestVersion = stableRelease?.version || null + } + + if (currentVersion && latestVersion) { + // Compare versions as dates if they match the expected format + const dateRegex = /\d{4}-\d{2}-\d{2}$/ + if (dateRegex.test(currentVersion) && dateRegex.test(latestVersion)) { + const currentDate = new Date(currentVersion.match(dateRegex)![0]) + const latestDate = new Date(latestVersion.match(dateRegex)![0]) + updateAvailable = latestDate > currentDate + } else { + // Fallback to string comparison + updateAvailable = currentVersion !== latestVersion + } + } + } else { + // No currentVersion; default to latest stable + const stableRelease = releases.find(release => release.isLatestStable) + latestVersion = stableRelease?.version || null + } + + // Fetch the release body for the latestVersion + if (latestVersion) { + const rawRelease = rawReleases.find(r => r.tag_name === latestVersion) + latestReleaseBody = rawRelease?.body || '' + } + } catch (error) { + console.error('Failed to fetch Docker details:', error instanceof Error ? error.message : error) + } + + return { + currentVersion, + latestVersion, + latestReleaseBody, + updateAvailable, + } + } + + private readonly DOCKER_GITHUB_API_URL = 'https://api.github.com/repos/homebridge/docker-homebridge/releases' + + /** + * Fetches the most recent releases (up to 100) of the homebridge/docker-homebridge package from GitHub, + * tagging test versions (tags starting with 'beta-' or 'test-') and the latest stable version (YYYY-MM-DD format). + * Includes a testTag field for test versions. + * @returns A promise resolving to an object with processed releases and raw release data, or empty arrays if an error occurs. + */ + public async getRecentReleases(): Promise<{ releases: DockerReleaseInfo[], rawReleases: DockerRelease[] }> { + try { + // Fetch the first page of up to 100 releases + const response = await fetch(`${this.DOCKER_GITHUB_API_URL}?per_page=100`, { + headers: { + Accept: 'application/vnd.github.v3+json', + // Optional: Add GitHub token for higher rate limits + // 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`, + }, + }) + + if (!response.ok) { + console.error(`GitHub API error: ${response.status} ${response.statusText}`) + return { releases: [], rawReleases: [] } + } + + const data: DockerRelease[] = await response.json() + + if (!Array.isArray(data)) { + console.error('Invalid response from GitHub API: Expected an array') + return { releases: [], rawReleases: [] } + } + + // Find the latest stable release by sorting YYYY-MM-DD tags + const stableReleases = data + .filter(release => /^\d{4}-\d{2}-\d{2}$/.test(release.tag_name)) // Stable: YYYY-MM-DD + .sort((a, b) => b.tag_name.localeCompare(a.tag_name)) // Sort descending (most recent first) + const latestStableTag = stableReleases[0]?.tag_name || null + + const releases = data.map((release) => { + const tagName = release.tag_name.toLowerCase() + let testTag: 'beta' | 'test' | null = null + if (tagName.startsWith('beta-')) { + testTag = 'beta' + } else if (tagName.startsWith('test-')) { + testTag = 'test' + } + + return { + version: release.tag_name, + publishedAt: release.published_at, + isPrerelease: release.prerelease, + isTest: testTag !== null, + testTag, + isLatestStable: release.tag_name === latestStableTag, + } + }) + + return { releases, rawReleases: data } + } catch (error) { + console.error('Failed to fetch docker-homebridge releases:', error instanceof Error ? error.message : error) + return { releases: [], rawReleases: [] } + } + } } diff --git a/ui/src/app/core/components/information/information.component.html b/ui/src/app/core/components/information/information.component.html index 5cdb3ff46..686161ed0 100644 --- a/ui/src/app/core/components/information/information.component.html +++ b/ui/src/app/core/components/information/information.component.html @@ -18,6 +18,8 @@

@if (message2) {

+ } @if (markdownMessage2) { + } diff --git a/ui/src/app/modules/status/widgets/update-info-widget/update-info-widget.component.ts b/ui/src/app/modules/status/widgets/update-info-widget/update-info-widget.component.ts index b543c4268..d9015d3ea 100644 --- a/ui/src/app/modules/status/widgets/update-info-widget/update-info-widget.component.ts +++ b/ui/src/app/modules/status/widgets/update-info-widget/update-info-widget.component.ts @@ -13,6 +13,13 @@ import { SettingsService } from '@/app/core/settings.service' import { IoNamespace, WsService } from '@/app/core/ws.service' import { HbV2ModalComponent } from '@/app/modules/status/widgets/update-info-widget/hb-v2-modal/hb-v2-modal.component' +interface DockerDetails { + currentVersion: string | undefined + latestVersion: string | null + latestReleaseBody: string + updateAvailable: boolean +} + @Component({ templateUrl: './update-info-widget.component.html', styleUrls: ['./update-info-widget.component.scss'], @@ -48,24 +55,36 @@ export class UpdateInfoWidgetComponent implements OnInit { public packageVersion = this.$settings.env.packageVersion public homebridgeVersion = this.$settings.env.homebridgeVersion + public dockerInfo: DockerDetails = { + currentVersion: undefined, + latestVersion: null, + latestReleaseBody: '', + updateAvailable: false, + } + + public dockerStatusDone = false as boolean + public dockerExpanded = false + public async ngOnInit() { this.io = this.$ws.getExistingNamespace('status') this.io.connected.subscribe(async () => { + await this.getNodeInfo() await Promise.all([ this.checkHomebridgeVersion(), this.checkHomebridgeUiVersion(), this.getOutOfDatePlugins(), - this.getNodeInfo(), + this.getDockerInfo(), ]) }) if (this.io.socket.connected) { + await this.getNodeInfo() await Promise.all([ this.checkHomebridgeVersion(), this.checkHomebridgeUiVersion(), this.getOutOfDatePlugins(), - this.getNodeInfo(), + this.getDockerInfo(), ]) } @@ -178,4 +197,39 @@ export class UpdateInfoWidgetComponent implements OnInit { this.$toastr.error(error.message, this.$translate.instant('toast.title_error')) } } + + private async getDockerInfo() { + if (this.serverInfo?.homebridgeRunningInDocker) { + try { + this.dockerInfo = await firstValueFrom(this.io.request('docker-version-check')) + this.dockerStatusDone = true + } catch (error) { + console.error(error) + this.$toastr.error(error.message, this.$translate.instant('toast.title_error')) + } + } else { + this.dockerStatusDone = true + } + } + + public toggleDockerExpand() { + this.dockerExpanded = !this.dockerExpanded + } + + public dockerUpdateModal() { + const ref = this.$modal.open(InformationComponent, { + size: 'lg', + backdrop: 'static', + }) + + ref.componentInstance.title = this.$translate.instant('status.widget.info.docker_update_title') + ref.componentInstance.message = this.$translate.instant('status.widget.info.docker_update_message') + ref.componentInstance.markdownMessage2 = this.dockerInfo.latestReleaseBody + ref.componentInstance.subtitle = (this.dockerInfo.currentVersion && this.dockerInfo.latestVersion) + ? `${this.dockerInfo.currentVersion} → ${this.dockerInfo.latestVersion}` + : this.$translate.instant('status.widget.unknown') + ref.componentInstance.ctaButtonLabel = this.$translate.instant('form.button_more_info') + ref.componentInstance.faIconClass = 'fab fa-fw fa-docker primary-text' + ref.componentInstance.ctaButtonLink = 'https://github.com/homebridge/docker-homebridge/wiki/How-To-Update-Docker-Homebridge' + } } diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 0bf8f84a5..987264223 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -542,6 +542,7 @@ "status.services.label_running": "Running", "status.services.updates": "Update Information", "status.uptime.title_uptime": "Uptime", + "status.widget.unknown": "Unknown", "status.widget.accessories.choose_accessories": "Choose the accessories to display in this widget from the Accessories page.", "status.widget.add.label_pairing_code": "Pairing Code", "status.widget.bridge.restart_error": "Failed to restart child bridge.", @@ -558,6 +559,8 @@ "status.widget.info.arch": "Arch.", "status.widget.info.config_path": "Config Path", "status.widget.info.docker": "Docker", + "status.widget.info.docker_update_message": "Updating the Docker container requires manually pulling the latest image and restarting the container. Homebridge does not perform this automatically. Refer to your Docker image's documentation for update instructions.", + "status.widget.info.docker_update_title": "Docker Container Update", "status.widget.info.glibc_message": "This message indicates that your operating system does not support newer versions of Node.js. To resolve this and be able to install updated versions of Node.js in the future, you will need to update your operating system to a more recent version.", "status.widget.info.glibc_title": "OS Update", "status.widget.info.hostname": "Hostname",