mirror of
https://github.com/selfhosters-cc/container-census.git
synced 2025-12-30 10:29:37 -06:00
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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 `
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user