Add telemetry features to Next.js frontend and version_checks to collector

- Add Community Analytics card to Next.js dashboard with toggle switch
- Expand telemetry settings page with collector management (add/remove/enable/disable)
- Add version_checks table to telemetry collector Database tab for viewing update checks
- Add telemetry types and API functions to Next.js frontend

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Self Hosters
2025-12-16 10:54:37 -05:00
parent fa367e2894
commit 474d78b801
7 changed files with 599 additions and 21 deletions

View File

@@ -337,12 +337,18 @@ func (s *Server) handleVersionCheck(w http.ResponseWriter, r *http.Request) {
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Version check: Failed to parse request body: %v", err)
respondError(w, http.StatusBadRequest, "Invalid request")
return
}
log.Printf("Version check: Received request - Installation: %s, Current Version: %s",
req.InstallationID, req.CurrentVersion)
// Validate required fields
if req.InstallationID == "" || req.CurrentVersion == "" {
log.Printf("Version check: Missing required fields (installation_id=%s, current_version=%s)",
req.InstallationID, req.CurrentVersion)
respondError(w, http.StatusBadRequest, "Missing installation_id or current_version")
return
}
@@ -356,16 +362,17 @@ func (s *Server) handleVersionCheck(w http.ResponseWriter, r *http.Request) {
`, req.InstallationID, req.CurrentVersion)
if err != nil {
log.Printf("Error recording version check: %v", err)
log.Printf("Version check: Error recording version check: %v", err)
// Continue anyway - don't fail the check
}
// Check GitHub API (using existing version package logic)
log.Printf("Version check: Querying GitHub for latest release...")
info := version.CheckLatestVersion()
// If there was an error checking GitHub, still return a valid response
if info.Error != nil {
log.Printf("Error checking GitHub for latest version: %v", info.Error)
log.Printf("Version check: ERROR checking GitHub: %v", info.Error)
respondJSON(w, http.StatusOK, map[string]interface{}{
"current_version": req.CurrentVersion,
"latest_version": "",
@@ -377,18 +384,29 @@ func (s *Server) handleVersionCheck(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("Version check: GitHub reports latest version: %s", info.LatestVersion)
// Compare the requesting server's version against GitHub latest
// (not the collector's own version)
updateAvailable := version.IsNewerVersion(info.LatestVersion, req.CurrentVersion)
// Return version info
respondJSON(w, http.StatusOK, map[string]interface{}{
log.Printf("Version check: Comparison - IsNewerVersion(%s, %s) = %v",
info.LatestVersion, req.CurrentVersion, updateAvailable)
// Prepare response
response := map[string]interface{}{
"current_version": req.CurrentVersion,
"latest_version": info.LatestVersion,
"update_available": updateAvailable,
"release_url": info.ReleaseURL,
"checked_at": time.Now().UTC(),
})
}
log.Printf("Version check: Sending response - Current: %s, Latest: %s, Update Available: %v, Release URL: %s",
req.CurrentVersion, info.LatestVersion, updateAvailable, info.ReleaseURL)
// Return version info
respondJSON(w, http.StatusOK, response)
}
// Save telemetry to database
@@ -1682,6 +1700,7 @@ func (s *Server) handleDatabaseView(w http.ResponseWriter, r *http.Request) {
"telemetry_reports": true,
"image_stats": true,
"submission_events": true,
"version_checks": true,
}
if table == "" {
@@ -1695,9 +1714,12 @@ func (s *Server) handleDatabaseView(w http.ResponseWriter, r *http.Request) {
// Default sort
if sortBy == "" {
if table == "submission_events" {
switch table {
case "submission_events":
sortBy = "id"
} else {
case "version_checks":
sortBy = "checked_at"
default:
sortBy = "timestamp"
}
}
@@ -1792,6 +1814,25 @@ func (s *Server) handleDatabaseView(w http.ResponseWriter, r *http.Request) {
`, whereClause, sortBy, sortOrder, argNum, argNum+1)
countQuery = fmt.Sprintf("SELECT COUNT(*) FROM submission_events%s", whereClause)
case "version_checks":
// Validate sort column for version_checks
validSortCols := map[string]bool{
"installation_id": true, "current_version": true, "checked_at": true,
}
if !validSortCols[sortBy] {
sortBy = "checked_at"
}
query = fmt.Sprintf(`
SELECT installation_id, current_version, checked_at
FROM version_checks
%s
ORDER BY %s %s
LIMIT $%d OFFSET $%d
`, whereClause, sortBy, sortOrder, argNum, argNum+1)
countQuery = fmt.Sprintf("SELECT COUNT(*) FROM version_checks%s", whereClause)
}
args = append(args, limit, offset)

View File

@@ -1,8 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { getContainers, getHosts, getNotificationStatus } from '@/lib/api';
import type { Container, Host } from '@/types';
import Link from 'next/link';
import { getContainers, getHosts, getNotificationStatus, getTelemetryEndpoints, getTelemetrySchedule, updateTelemetryEndpoint } from '@/lib/api';
import type { Container, Host, TelemetryEndpoint, TelemetrySchedule } from '@/types';
function StatCard({ label, value, icon, color = 'text-[var(--text-primary)]' }: {
label: string;
@@ -155,12 +156,145 @@ function RecentContainers({ containers }: { containers: Container[] }) {
);
}
function CommunityAnalyticsCard({
endpoints,
schedule,
onToggle,
isToggling
}: {
endpoints: TelemetryEndpoint[];
schedule: TelemetrySchedule | null;
onToggle: (enabled: boolean) => void;
isToggling: boolean;
}) {
const communityEndpoint = endpoints.find(e => e.name === 'community');
const isEnabled = communityEndpoint?.enabled ?? false;
return (
<div className="bg-[var(--bg-secondary)] border border-[var(--accent)] rounded-lg p-4">
{/* Header */}
<div className="flex items-start justify-between mb-4 pb-4 border-b border-[var(--border)]">
<div>
<h3 className="text-lg font-semibold">Community Analytics</h3>
<p className="text-sm text-[var(--text-tertiary)]">
Help improve Container Census by sharing anonymous usage statistics
</p>
</div>
<div className="flex flex-col items-center gap-1 p-2 bg-[var(--bg-tertiary)] rounded-lg border border-[var(--border)]">
<span className="text-xs text-[var(--text-tertiary)]">Share Data</span>
<button
onClick={() => onToggle(!isEnabled)}
disabled={isToggling || !communityEndpoint}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isEnabled ? 'bg-[var(--accent)]' : 'bg-[var(--bg-tertiary)]'
} ${isToggling ? 'opacity-50 cursor-wait' : ''}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
{/* Content */}
<div className="mb-4">
{!communityEndpoint ? (
<div className="p-3 bg-[var(--bg-tertiary)] rounded-lg text-center">
<p className="text-[var(--text-tertiary)]">Telemetry status unavailable</p>
</div>
) : !isEnabled ? (
<div className="p-3 bg-[rgba(245,158,11,0.1)] rounded-lg border border-[var(--warning)]">
<p className="text-[var(--warning)] font-semibold mb-1">Telemetry Disabled</p>
<p className="text-sm text-[var(--text-secondary)]">
Enable telemetry to contribute anonymous usage statistics and help improve Container Census.
</p>
</div>
) : (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-[var(--text-secondary)]">Status</span>
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-[var(--success)] text-white">
Active
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--text-secondary)]">Next Submission</span>
<span className="font-semibold">
{schedule?.next_submission
? new Date(schedule.next_submission).toLocaleString()
: 'Unknown'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[var(--text-secondary)]">Frequency</span>
<span className="font-semibold">
{schedule?.interval_hours ? `${schedule.interval_hours}h` : 'Unknown'}
</span>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-3 pt-4 border-t border-[var(--border)]">
<a
href="https://selfhosters.cc/stats"
target="_blank"
rel="noopener noreferrer"
className="flex-1 px-4 py-2 text-sm text-center bg-[var(--bg-tertiary)] border border-[var(--border)] rounded hover:bg-[var(--bg-primary)] transition-colors"
>
View Community Dashboard
</a>
<Link
href="/settings"
className="flex-1 px-4 py-2 text-sm text-center bg-[var(--accent)] text-white rounded hover:opacity-90 transition-opacity"
>
Configure Telemetry
</Link>
</div>
</div>
);
}
export default function DashboardPage() {
const [containers, setContainers] = useState<Container[]>([]);
const [hosts, setHosts] = useState<Host[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [telemetryEndpoints, setTelemetryEndpoints] = useState<TelemetryEndpoint[]>([]);
const [telemetrySchedule, setTelemetrySchedule] = useState<TelemetrySchedule | null>(null);
const [isTogglingTelemetry, setIsTogglingTelemetry] = useState(false);
const [loading, setLoading] = useState(true);
const loadTelemetryData = async () => {
try {
const [endpoints, schedule] = await Promise.all([
getTelemetryEndpoints().catch(() => []),
getTelemetrySchedule().catch(() => null),
]);
setTelemetryEndpoints(endpoints);
setTelemetrySchedule(schedule);
} catch (error) {
console.error('Failed to load telemetry data:', error);
}
};
const handleTelemetryToggle = async (enabled: boolean) => {
const communityEndpoint = telemetryEndpoints.find(e => e.name === 'community');
if (!communityEndpoint) return;
setIsTogglingTelemetry(true);
try {
await updateTelemetryEndpoint(communityEndpoint.name, { enabled });
await loadTelemetryData();
} catch (error) {
console.error('Failed to toggle telemetry:', error);
} finally {
setIsTogglingTelemetry(false);
}
};
useEffect(() => {
async function loadData() {
try {
@@ -180,9 +314,13 @@ export default function DashboardPage() {
}
loadData();
loadTelemetryData();
// Refresh every 30 seconds
const interval = setInterval(loadData, 30000);
const interval = setInterval(() => {
loadData();
loadTelemetryData();
}, 30000);
return () => clearInterval(interval);
}, []);
@@ -227,6 +365,12 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<RecentContainers containers={containers} />
<CommunityAnalyticsCard
endpoints={telemetryEndpoints}
schedule={telemetrySchedule}
onToggle={handleTelemetryToggle}
isToggling={isTogglingTelemetry}
/>
</div>
</div>
);

View File

@@ -1,8 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { getHealth, clearDismissedVersion } from '@/lib/api';
import type { HealthStatus, VersionCheckResponse } from '@/types';
import { getHealth, clearDismissedVersion, getTelemetryStatus, updateTelemetryEndpoint, createTelemetryEndpoint, deleteTelemetryEndpoint, testTelemetryEndpoint } from '@/lib/api';
import type { HealthStatus, VersionCheckResponse, TelemetryEndpoint, TelemetryEndpointCreate } from '@/types';
interface Settings {
scanner: {
@@ -69,6 +69,28 @@ export default function SettingsPage() {
const [checkingUpdates, setCheckingUpdates] = useState(false);
const [versionInfo, setVersionInfo] = useState<VersionCheckResponse | null>(null);
// Telemetry state
const [collectors, setCollectors] = useState<TelemetryEndpoint[]>([]);
const [togglingCollector, setTogglingCollector] = useState<string | null>(null);
const [deletingCollector, setDeletingCollector] = useState<string | null>(null);
// Add collector form state
const [newCollectorName, setNewCollectorName] = useState('');
const [newCollectorUrl, setNewCollectorUrl] = useState('');
const [newCollectorApiKey, setNewCollectorApiKey] = useState('');
const [addingCollector, setAddingCollector] = useState(false);
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const loadCollectors = async () => {
try {
const data = await getTelemetryStatus();
setCollectors(data);
} catch (error) {
console.error('Failed to load collectors:', error);
}
};
const loadData = async () => {
try {
const [settingsData, healthData] = await Promise.all([
@@ -86,6 +108,7 @@ export default function SettingsPage() {
useEffect(() => {
loadData();
loadCollectors();
}, []);
const handleCheckUpdates = async () => {
@@ -150,6 +173,85 @@ export default function SettingsPage() {
handleSave('telemetry_interval', updated);
};
const handleToggleCollector = async (name: string, enabled: boolean) => {
setTogglingCollector(name);
try {
await updateTelemetryEndpoint(name, { enabled });
await loadCollectors();
} catch (error) {
console.error('Failed to toggle collector:', error);
} finally {
setTogglingCollector(null);
}
};
const handleDeleteCollector = async (name: string) => {
if (!confirm(`Are you sure you want to delete the collector "${name}"?`)) return;
setDeletingCollector(name);
try {
await deleteTelemetryEndpoint(name);
await loadCollectors();
} catch (error) {
console.error('Failed to delete collector:', error);
} finally {
setDeletingCollector(null);
}
};
const handleTestConnection = async () => {
if (!newCollectorUrl) return;
setTestingConnection(true);
setTestResult(null);
try {
await testTelemetryEndpoint(newCollectorUrl, newCollectorApiKey || undefined);
setTestResult({ success: true, message: 'Connection successful!' });
} catch (error) {
setTestResult({ success: false, message: error instanceof Error ? error.message : 'Connection failed' });
} finally {
setTestingConnection(false);
}
};
const handleAddCollector = async () => {
if (!newCollectorName || !newCollectorUrl) return;
setAddingCollector(true);
try {
const endpoint: TelemetryEndpointCreate = {
name: newCollectorName,
url: newCollectorUrl,
enabled: true,
api_key: newCollectorApiKey || undefined,
};
await createTelemetryEndpoint(endpoint);
await loadCollectors();
// Reset form
setNewCollectorName('');
setNewCollectorUrl('');
setNewCollectorApiKey('');
setTestResult(null);
} catch (error) {
console.error('Failed to add collector:', error);
alert(error instanceof Error ? error.message : 'Failed to add collector');
} finally {
setAddingCollector(false);
}
};
const formatStatus = (collector: TelemetryEndpoint) => {
if (collector.last_success) {
const date = new Date(collector.last_success);
return { text: `Last success: ${date.toLocaleString()}`, type: 'success' as const };
}
if (collector.last_failure) {
const date = new Date(collector.last_failure);
return { text: `Last failure: ${date.toLocaleString()}`, type: 'error' as const };
}
return { text: 'No submissions yet', type: 'neutral' as const };
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@@ -231,10 +333,15 @@ export default function SettingsPage() {
{/* Telemetry Settings */}
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
<h2 className="text-lg font-semibold mb-4">Telemetry Settings</h2>
<h2 className="text-lg font-semibold mb-4">Telemetry Collectors</h2>
<p className="text-sm text-[var(--text-tertiary)] mb-4">
Configure telemetry endpoints to track anonymous container usage statistics.
</p>
{/* Submission Frequency */}
<SettingRow
label="Report Frequency"
description="How often to submit anonymous telemetry data"
label="Submission Frequency"
description="How often to submit telemetry data to all enabled collectors"
>
<div className="flex items-center gap-2">
<select
@@ -243,17 +350,240 @@ export default function SettingsPage() {
disabled={saving === 'telemetry_interval'}
className="bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2 text-sm"
>
<option value="0">Disabled</option>
<option value="24">Daily</option>
<option value="168">Weekly</option>
<option value="168">Weekly (recommended)</option>
<option value="336">Every 2 weeks</option>
<option value="720">Monthly</option>
</select>
{saving === 'telemetry_interval' && <span className="text-sm text-[var(--text-secondary)]">Saving...</span>}
</div>
</SettingRow>
<div className="text-xs text-[var(--text-secondary)] mt-2">
Telemetry helps improve Container Census by sharing anonymous usage statistics.
No container names, images, or sensitive data is collected.
{/* Community Collector */}
{(() => {
const communityCollector = collectors.find(c => c.name === 'community');
if (!communityCollector) return null;
const status = formatStatus(communityCollector);
return (
<div className="mt-6 p-4 bg-[var(--bg-tertiary)] rounded-lg border-2 border-[var(--accent)]">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-semibold flex items-center gap-2">
Community Collector
<span className={`px-2 py-0.5 text-xs rounded-full ${
communityCollector.enabled
? 'bg-[var(--success)] text-white'
: 'bg-[var(--text-tertiary)] text-white'
}`}>
{communityCollector.enabled ? 'Enabled' : 'Disabled'}
</span>
</h3>
<p className="text-sm text-[var(--text-tertiary)] mt-1">
Help improve Container Census by sharing anonymous usage statistics.
</p>
<p className="text-xs font-mono text-[var(--text-tertiary)] mt-1">
{communityCollector.url}
</p>
</div>
<button
onClick={() => handleToggleCollector(communityCollector.name, !communityCollector.enabled)}
disabled={togglingCollector === communityCollector.name}
className={`px-4 py-2 text-sm rounded transition-colors ${
communityCollector.enabled
? 'bg-[var(--warning)] text-white hover:opacity-90'
: 'bg-[var(--accent)] text-white hover:opacity-90'
} disabled:opacity-50`}
>
{togglingCollector === communityCollector.name ? 'Updating...' : communityCollector.enabled ? 'Disable' : 'Enable'}
</button>
</div>
{/* Privacy Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-3 bg-[var(--bg-secondary)] rounded-lg mb-3">
<div>
<h4 className="text-xs font-semibold text-[var(--success)] mb-2">What gets shared:</h4>
<ul className="text-xs text-[var(--text-tertiary)] space-y-1">
<li>Container Census version</li>
<li>Number of containers and hosts</li>
<li>Popular container images (names only)</li>
<li>Container registry distribution</li>
<li>Geographic region (timezone-based)</li>
</ul>
</div>
<div>
<h4 className="text-xs font-semibold text-[var(--danger)] mb-2">What is NOT shared:</h4>
<ul className="text-xs text-[var(--text-tertiary)] space-y-1">
<li>Host names or IP addresses</li>
<li>Container names or env variables</li>
<li>Any credentials or secrets</li>
<li>Personal information</li>
</ul>
</div>
</div>
{/* Status */}
<div className={`text-xs ${
status.type === 'success' ? 'text-[var(--success)]' :
status.type === 'error' ? 'text-[var(--danger)]' :
'text-[var(--text-tertiary)]'
}`}>
{status.text}
</div>
{communityCollector.last_failure_reason && (
<div className="text-xs text-[var(--danger)] mt-1" title={communityCollector.last_failure_reason}>
{communityCollector.last_failure_reason.substring(0, 80)}
{communityCollector.last_failure_reason.length > 80 ? '...' : ''}
</div>
)}
{/* View Dashboard Link */}
<div className="mt-3 pt-3 border-t border-[var(--border)]">
<a
href="https://selfhosters.cc/stats"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[var(--accent)] hover:underline"
>
View Community Dashboard
</a>
</div>
</div>
);
})()}
{/* Custom Collectors */}
{(() => {
const customCollectors = collectors.filter(c => c.name !== 'community');
return (
<div className="mt-6">
<h3 className="font-semibold mb-3">Custom Collectors</h3>
{customCollectors.length === 0 ? (
<p className="text-sm text-[var(--text-tertiary)] italic">No custom collectors configured.</p>
) : (
<div className="space-y-3">
{customCollectors.map(collector => {
const status = formatStatus(collector);
return (
<div key={collector.name} className="p-3 bg-[var(--bg-tertiary)] rounded-lg">
<div className="flex items-start justify-between">
<div>
<div className="font-medium flex items-center gap-2">
{collector.name}
<span className={`px-2 py-0.5 text-xs rounded-full ${
collector.enabled
? 'bg-[var(--success)] text-white'
: 'bg-[var(--text-tertiary)] text-white'
}`}>
{collector.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<p className="text-xs font-mono text-[var(--text-tertiary)] mt-1">{collector.url}</p>
{collector.api_key && (
<p className="text-xs text-[var(--text-tertiary)] mt-1">API Key configured</p>
)}
<div className={`text-xs mt-1 ${
status.type === 'success' ? 'text-[var(--success)]' :
status.type === 'error' ? 'text-[var(--danger)]' :
'text-[var(--text-tertiary)]'
}`}>
{status.text}
</div>
{collector.last_failure_reason && (
<div className="text-xs text-[var(--danger)] mt-1">
{collector.last_failure_reason.substring(0, 60)}
{collector.last_failure_reason.length > 60 ? '...' : ''}
</div>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => handleToggleCollector(collector.name, !collector.enabled)}
disabled={togglingCollector === collector.name}
className="px-3 py-1 text-xs bg-[var(--bg-secondary)] border border-[var(--border)] rounded hover:bg-[var(--bg-primary)] disabled:opacity-50"
>
{togglingCollector === collector.name ? '...' : collector.enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => handleDeleteCollector(collector.name)}
disabled={deletingCollector === collector.name}
className="px-3 py-1 text-xs text-[var(--danger)] border border-[var(--danger)] rounded hover:bg-[var(--danger)] hover:text-white disabled:opacity-50"
>
{deletingCollector === collector.name ? '...' : 'Delete'}
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
})()}
{/* Add New Collector Form */}
<div className="mt-6 p-4 bg-[var(--bg-tertiary)] rounded-lg">
<h3 className="font-semibold mb-3">Add New Collector</h3>
<div className="space-y-3">
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Name *</label>
<input
type="text"
value={newCollectorName}
onChange={(e) => setNewCollectorName(e.target.value)}
placeholder="My Telemetry Server"
className="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border)] rounded"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">URL *</label>
<input
type="url"
value={newCollectorUrl}
onChange={(e) => setNewCollectorUrl(e.target.value)}
placeholder="https://telemetry.example.com/api/ingest"
className="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border)] rounded font-mono"
/>
</div>
<div>
<label className="block text-xs text-[var(--text-tertiary)] mb-1">API Key (optional)</label>
<input
type="password"
value={newCollectorApiKey}
onChange={(e) => setNewCollectorApiKey(e.target.value)}
placeholder="Optional API key for authentication"
className="w-full px-3 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border)] rounded"
/>
</div>
{testResult && (
<div className={`p-2 rounded text-sm ${
testResult.success
? 'bg-[rgba(34,197,94,0.1)] text-[var(--success)] border border-[var(--success)]'
: 'bg-[rgba(239,68,68,0.1)] text-[var(--danger)] border border-[var(--danger)]'
}`}>
{testResult.message}
</div>
)}
<div className="flex gap-2 pt-2">
<button
onClick={handleTestConnection}
disabled={testingConnection || !newCollectorUrl}
className="px-4 py-2 text-sm bg-[var(--bg-secondary)] border border-[var(--border)] rounded hover:bg-[var(--bg-primary)] disabled:opacity-50"
>
{testingConnection ? 'Testing...' : 'Test Connection'}
</button>
<button
onClick={handleAddCollector}
disabled={addingCollector || !newCollectorName || !newCollectorUrl}
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
>
{addingCollector ? 'Adding...' : 'Add Collector'}
</button>
</div>
</div>
</div>
</div>

View File

@@ -187,6 +187,20 @@ export const triggerScan = () =>
// Telemetry
export const submitTelemetry = () =>
fetchApi<void>('/telemetry/submit', { method: 'POST' });
export const getTelemetryEndpoints = () =>
fetchApi<import('@/types').TelemetryEndpoint[]>('/telemetry/endpoints');
export const updateTelemetryEndpoint = (name: string, data: { enabled: boolean }) =>
fetchApi<void>(`/telemetry/endpoints/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data) });
export const createTelemetryEndpoint = (endpoint: import('@/types').TelemetryEndpointCreate) =>
fetchApi<void>('/telemetry/endpoints', { method: 'POST', body: JSON.stringify(endpoint) });
export const deleteTelemetryEndpoint = (name: string) =>
fetchApi<void>(`/telemetry/endpoints/${encodeURIComponent(name)}`, { method: 'DELETE' });
export const getTelemetryStatus = () =>
fetchApi<import('@/types').TelemetryEndpoint[]>('/telemetry/status');
export const getTelemetrySchedule = () =>
fetchApi<import('@/types').TelemetrySchedule>('/telemetry/schedule');
export const testTelemetryEndpoint = (url: string, apiKey?: string) =>
fetchApi<void>('/telemetry/test-endpoint', { method: 'POST', body: JSON.stringify({ url, api_key: apiKey }) });
// Container logs
export const getContainerLogs = (hostId: number, containerId: string, tail?: number) =>

View File

@@ -368,6 +368,32 @@ export interface UpdateDBResponse {
}>;
}
// Telemetry types
export interface TelemetryEndpoint {
name: string;
url: string;
enabled: boolean;
api_key?: string;
last_success?: string;
last_failure?: string;
last_failure_reason?: string;
}
export interface TelemetryEndpointCreate {
name: string;
url: string;
enabled?: boolean;
api_key?: string;
}
export interface TelemetrySchedule {
enabled_endpoints: number;
interval_hours: number;
last_submission?: string;
next_submission?: string;
message?: string;
}
// Auth types
export interface LoginRequest {
username: string;

View File

@@ -791,6 +791,7 @@ async function loadVersion() {
const response = await fetch('/health');
const data = await response.json();
const badge = document.getElementById('versionBadge');
const buildTimeBadge = document.getElementById('buildTimeBadge');
if (data.version) {
// Format build time for display
@@ -798,6 +799,15 @@ async function loadVersion() {
if (data.build_time && data.build_time !== 'unknown') {
const buildDate = new Date(data.build_time);
buildTimeText = `\nBuilt: ${buildDate.toLocaleString()}`;
// Show build time badge
const buildDateStr = buildDate.toLocaleDateString();
const buildTimeStr = buildDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
buildTimeBadge.textContent = `🔨 ${buildDateStr} ${buildTimeStr}`;
buildTimeBadge.title = `Built: ${buildDate.toLocaleString()}`;
buildTimeBadge.style.display = 'inline-block';
} else {
buildTimeBadge.style.display = 'none';
}
if (data.update_available && data.latest_version) {
@@ -1498,7 +1508,8 @@ async function loadDatabaseView() {
// For telemetry_reports, sort by timestamp to show recently updated records first
const sortBy = (table === 'telemetry_reports') ? 'timestamp' :
(table === 'submission_events') ? 'id' : 'timestamp';
(table === 'submission_events') ? 'id' :
(table === 'version_checks') ? 'checked_at' : 'timestamp';
const params = new URLSearchParams({
table: table,
@@ -1597,6 +1608,16 @@ function renderDatabaseRecord(record, recordId, tableName) {
</div>
`;
break;
case 'version_checks':
summary = `
<div class="db-record-summary">
<span class="db-field"><strong>Installation:</strong> ${truncateId(record.installation_id)}</span>
<span class="db-field"><strong>Version:</strong> ${record.current_version || 'N/A'}</span>
<span class="db-field"><strong>Checked:</strong> ${formatTimestamp(record.checked_at)}</span>
</div>
`;
break;
}
return `

View File

@@ -22,6 +22,7 @@
<span id="eventCounter" class="event-counter" style="display: none;">0</span>
</div>
<span id="versionBadge" class="version-badge">v0.0.0</span>
<span id="buildTimeBadge" class="version-badge" style="display: none;">🔨 Building...</span>
</div>
</div>
</header>
@@ -263,6 +264,7 @@
<option value="telemetry_reports">Telemetry Reports</option>
<option value="submission_events">Submission Events</option>
<option value="image_stats">Image Stats</option>
<option value="version_checks">Version Checks</option>
</select>
</label>