Skip to content

Commit b6be4b8

Browse files
Tim020claude
andauthored
Add server-side version checker (#878) (#880)
Adds functionality for the server to check whether it is running the latest version of DigiScript, displayed in System Config -> System tab. Backend: - New VersionChecker service that queries GitHub Releases API - Checks on startup and periodically (every hour) - Caches results for fast API responses - New /api/v1/version/status and /api/v1/version/check endpoints Frontend: - Version row in System Config showing current version with status badge - Green "Up to date", yellow "Update Available", red "Unable to check" - "Check Now" button for manual refresh - Link to release notes when update is available Also fixes circular import issues by adding `from __future__ import annotations` to settings.py and base_controller.py. Closes #878 Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 32a5019 commit b6be4b8

File tree

5 files changed

+460
-0
lines changed

5 files changed

+460
-0
lines changed

client/src/vue_components/config/ConfigSystem.vue

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,45 @@
3838
</b-button>
3939
</b-td>
4040
</b-tr>
41+
<b-tr>
42+
<b-td>
43+
<b>Version</b>
44+
</b-td>
45+
<b-td>
46+
{{ versionStatus.current_version || 'Unknown' }}
47+
<b-badge :variant="getVersionStatusVariant()" pill>
48+
{{ getVersionStatusText() }}
49+
</b-badge>
50+
<template v-if="versionStatus.update_available && versionStatus.latest_version">
51+
<br />
52+
<small class="text-muted">
53+
Latest: {{ versionStatus.latest_version }}
54+
<a
55+
v-if="versionStatus.release_url"
56+
:href="versionStatus.release_url"
57+
target="_blank"
58+
rel="noopener noreferrer"
59+
>
60+
(Release Notes)
61+
</a>
62+
</small>
63+
</template>
64+
<template v-if="versionStatus.check_error">
65+
<br />
66+
<small class="text-danger">{{ versionStatus.check_error }}</small>
67+
</template>
68+
</b-td>
69+
<b-td>
70+
<b-button
71+
variant="outline-success"
72+
:disabled="isCheckingVersion"
73+
@click="checkForUpdates"
74+
>
75+
<b-spinner v-if="isCheckingVersion" small class="mr-1" />
76+
Check Now
77+
</b-button>
78+
</b-td>
79+
</b-tr>
4180
</b-tbody>
4281
</b-table-simple>
4382
<b-modal
@@ -218,6 +257,17 @@ export default {
218257
],
219258
clientTimeout: null,
220259
loading: true,
260+
versionStatus: {
261+
current_version: null,
262+
latest_version: null,
263+
update_available: false,
264+
release_url: null,
265+
last_checked: null,
266+
check_error: null,
267+
},
268+
isCheckingVersion: false,
269+
currentTime: Date.now(),
270+
timeUpdateInterval: null,
221271
};
222272
},
223273
validations: {
@@ -254,10 +304,20 @@ export default {
254304
await this.getAvailableShows();
255305
await this.getConnectedClients();
256306
await this.GET_SCRIPT_MODES();
307+
await this.getVersionStatus();
257308
this.loading = false;
309+
310+
// Start time update interval for reactive "time ago" display
311+
this.timeUpdateInterval = setInterval(() => {
312+
this.currentTime = Date.now();
313+
}, 1000);
258314
},
259315
destroyed() {
260316
clearTimeout(this.clientTimeout);
317+
if (this.timeUpdateInterval) {
318+
clearInterval(this.timeUpdateInterval);
319+
this.timeUpdateInterval = null;
320+
}
261321
},
262322
methods: {
263323
async getAvailableShows() {
@@ -377,6 +437,93 @@ export default {
377437
this.isSubmittingLoad = false;
378438
}
379439
},
440+
async getVersionStatus() {
441+
try {
442+
const response = await fetch(`${makeURL('/api/v1/version/status')}`);
443+
if (response.ok) {
444+
this.versionStatus = await response.json();
445+
} else {
446+
log.error('Unable to get version status');
447+
}
448+
} catch (error) {
449+
log.error('Error fetching version status:', error);
450+
}
451+
},
452+
async checkForUpdates() {
453+
if (this.isCheckingVersion) {
454+
return;
455+
}
456+
457+
this.isCheckingVersion = true;
458+
459+
try {
460+
const response = await fetch(`${makeURL('/api/v1/version/check')}`, {
461+
method: 'POST',
462+
});
463+
if (response.ok) {
464+
this.versionStatus = await response.json();
465+
if (this.versionStatus.update_available) {
466+
this.$toast.info(`Update available: ${this.versionStatus.latest_version}`);
467+
} else if (!this.versionStatus.check_error) {
468+
this.$toast.success('You are running the latest version');
469+
}
470+
} else {
471+
this.$toast.error('Unable to check for updates');
472+
log.error('Unable to check for updates');
473+
}
474+
} catch (error) {
475+
this.$toast.error('Unable to check for updates');
476+
log.error('Error checking for updates:', error);
477+
} finally {
478+
this.isCheckingVersion = false;
479+
}
480+
},
481+
getVersionStatusVariant() {
482+
if (!this.versionStatus.current_version) {
483+
return 'secondary';
484+
}
485+
if (this.versionStatus.check_error) {
486+
return 'danger';
487+
}
488+
if (this.versionStatus.update_available) {
489+
return 'warning';
490+
}
491+
return 'success';
492+
},
493+
getVersionStatusText() {
494+
if (!this.versionStatus.current_version) {
495+
return 'Loading...';
496+
}
497+
if (this.versionStatus.check_error) {
498+
return 'Unable to check';
499+
}
500+
if (this.versionStatus.update_available) {
501+
return 'Update Available';
502+
}
503+
return 'Up to date';
504+
},
505+
formatTimeAgo(isoTimestamp) {
506+
if (!isoTimestamp) {
507+
return 'Never';
508+
}
509+
510+
const timestamp = new Date(isoTimestamp).getTime();
511+
const seconds = Math.floor((this.currentTime - timestamp) / 1000);
512+
513+
if (seconds < 10) {
514+
return 'Just now';
515+
}
516+
if (seconds < 60) {
517+
return `${seconds} seconds ago`;
518+
}
519+
if (seconds < 3600) {
520+
return `${Math.floor(seconds / 60)} minutes ago`;
521+
}
522+
if (seconds < 86400) {
523+
return `${Math.floor(seconds / 3600)} hours ago`;
524+
}
525+
return `${Math.floor(seconds / 86400)} days ago`;
526+
},
380527
...mapMutations(['UPDATE_SHOWS']),
381528
...mapActions(['GET_SCRIPT_MODES']),
382529
},

docs/pages/user_config.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ The **System Config** section, accessible from the top navigation bar, provides
44

55
![](../images/config_system/system_overview.png)
66

7+
### System Tab
8+
9+
The **System** tab provides an overview of the current system state:
10+
11+
- **Current Show**: Displays the currently loaded show name, with buttons to load an existing show or set up a new one.
12+
- **Connected Clients**: Shows the number of WebSocket clients currently connected to the server. Click "View Clients" to see details about each connected session.
13+
- **Version**: Displays the current DigiScript version and checks for available updates.
14+
15+
#### Version Checker
16+
17+
The version checker automatically checks for new DigiScript releases when the server starts, and periodically (every hour) thereafter. The version status shows:
18+
19+
- **Current version**: The version of DigiScript currently running
20+
- **Status badge**: Indicates whether you're up to date (green), an update is available (yellow), or the check failed (red)
21+
- **Latest version**: When an update is available, shows the newest version number with a link to the release notes
22+
- **Last checked**: Timestamp of when the version was last checked
23+
24+
Click the **Check Now** button to manually trigger a version check against the GitHub releases.
25+
726
### System Settings
827

928
The **Settings** tab allows you to configure system-wide settings that apply across all shows:

server/controllers/api/version.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from utils.web.base_controller import BaseAPIController
2+
from utils.web.route import ApiRoute, ApiVersion
3+
from utils.web.web_decorators import api_authenticated, require_admin
4+
5+
6+
@ApiRoute("version/status", ApiVersion.V1)
7+
class VersionStatusController(BaseAPIController):
8+
@api_authenticated
9+
async def get(self):
10+
"""
11+
Get the current version status.
12+
13+
Returns information about the running version, latest available version,
14+
whether an update is available, and when the last check occurred.
15+
16+
:returns: JSON response with version status.
17+
"""
18+
version_checker = self.application.version_checker
19+
if not version_checker:
20+
await self.finish(
21+
{
22+
"error": "Version checker not initialized",
23+
"current_version": None,
24+
"latest_version": None,
25+
"update_available": False,
26+
"release_url": None,
27+
"last_checked": None,
28+
"check_error": "Service not available",
29+
}
30+
)
31+
return
32+
33+
await self.finish(version_checker.status.as_json())
34+
35+
36+
@ApiRoute("version/check", ApiVersion.V1)
37+
class VersionCheckController(BaseAPIController):
38+
@api_authenticated
39+
@require_admin
40+
async def post(self):
41+
"""
42+
Trigger a manual version check.
43+
44+
Forces an immediate check against the GitHub API, updating the
45+
cached version status.
46+
47+
:returns: JSON response with updated version status.
48+
"""
49+
version_checker = self.application.version_checker
50+
if not version_checker:
51+
self.set_status(503)
52+
await self.finish(
53+
{
54+
"error": "Version checker not initialized",
55+
}
56+
)
57+
return
58+
59+
status = await version_checker.check_for_updates()
60+
await self.finish(status.as_json())

server/digi_server/app_server.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from utils.exceptions import DatabaseTypeException, DatabaseUpgradeRequired
3333
from utils.mdns_service import MDNSAdvertiser
3434
from utils.module_discovery import get_resource_path, is_frozen
35+
from utils.version_checker import VersionChecker
3536
from utils.web.jwt_service import JWTService
3637
from utils.web.route import Route
3738

@@ -60,6 +61,7 @@ def __init__(
6061
self._db: DigiSQLAlchemy = models.db
6162
self.jwt_service: JWTService = None
6263
self.mdns_advertiser: Optional[MDNSAdvertiser] = None
64+
self.version_checker: Optional[VersionChecker] = None
6365

6466
db_path: str = self.digi_settings.settings.get("db_path").get_value()
6567
if db_path.startswith("sqlite://"):
@@ -351,6 +353,7 @@ def _check_migrations(self):
351353
async def configure(self):
352354
await self._configure_logging()
353355
await self.start_mdns_advertising()
356+
await self.start_version_checker()
354357

355358
async def _configure_logging(self):
356359
get_logger().info("Reconfiguring logging!")
@@ -528,3 +531,16 @@ async def _toggle_mdns_advertising(self) -> None:
528531
else:
529532
# Stop advertising
530533
await self.stop_mdns_advertising()
534+
535+
async def start_version_checker(self) -> None:
536+
"""Start the version checker service."""
537+
if not self.version_checker:
538+
self.version_checker = VersionChecker(application=self)
539+
540+
await self.version_checker.start()
541+
542+
async def stop_version_checker(self) -> None:
543+
"""Stop the version checker service if it's running."""
544+
if self.version_checker:
545+
await self.version_checker.stop()
546+
self.version_checker = None

0 commit comments

Comments
 (0)