feat: upstream tracking UI + Attention panel + semver classifier
UpstreamTracking page (/settings/upstream): - Full CRUD: add form, common-stack one-click chips (postgres, nginx, node, python, redis, docker, go, kubernetes, ubuntu, debian), table with sync-now / source-link / remove per row - Drift highlighting: past-EOL rows red, behind-upstream rows amber - Surfaces last_error and last_synced_at per row - Wired into App.tsx routes + Settings.tsx quick-action card AttentionPanel on Dashboard: - Aggregates offline agents, failed updates, past-EOL software, recent drift events into one feed - Severity-ranked (eol > failed > major > offline > minor > patch) - Renders nothing when state is clean — calm dashboards stay calm Semver-aware classifier: - services/upstream/version.go: ParseVersion + CompareVersions + ClassifyDrift; handles messy versions (15.4, v1.27.3, 1.0.0-rc1+meta, 20231130-1.fc40) - SemVer convention: release > prerelease (empty suffix wins) - Replaces lexicographic compare in syncer Settings page cleanup: - Drop "System Configuration — coming soon" dead card - Drop "Implementation Status" yellow-box fluff - Fix broken Tailwind autoRefresh toggle (dynamic class wouldn't JIT)
This commit is contained in:
parent
86da7471ec
commit
bcd67e04bf
8 changed files with 688 additions and 49 deletions
|
|
@ -3,7 +3,6 @@ package upstream
|
|||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
|
|
@ -142,8 +141,8 @@ func (s *Syncer) syncOne(ctx context.Context, row models.TrackedSoftware) {
|
|||
// for severity: major if the first dotted segment changed, else minor.
|
||||
// "eol" overrides if endoflife.date now says the deployed version's branch
|
||||
// is past EOL.
|
||||
if priorLatest != "" && priorLatest != release.Version {
|
||||
severity := classifyDrift(priorLatest, release.Version)
|
||||
if priorLatest != "" && CompareVersions(priorLatest, release.Version) != 0 {
|
||||
severity := ClassifyDrift(priorLatest, release.Version)
|
||||
from, to := priorLatest, release.Version
|
||||
var note *string
|
||||
if release.EOLAt != nil && row.CurrentVersion != nil && release.EOLAt.Before(time.Now()) {
|
||||
|
|
@ -158,12 +157,3 @@ func (s *Syncer) syncOne(ctx context.Context, row models.TrackedSoftware) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func classifyDrift(from, to string) string {
|
||||
fParts := strings.SplitN(from, ".", 2)
|
||||
tParts := strings.SplitN(to, ".", 2)
|
||||
if len(fParts) > 0 && len(tParts) > 0 && fParts[0] != tParts[0] {
|
||||
return "major"
|
||||
}
|
||||
return "minor"
|
||||
}
|
||||
|
|
|
|||
116
server/internal/services/upstream/version.go
Normal file
116
server/internal/services/upstream/version.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package upstream
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Version comparison that doesn't pretend to be full SemVer 2.0.0. Real
|
||||
// upstream versions are messy — "15.4", "v1.27.3", "1.0.0-rc1+meta",
|
||||
// "20231130-1.fc40". We extract the leading numeric segments and compare
|
||||
// those; non-numeric tails fall back to lexicographic.
|
||||
//
|
||||
// This is deliberately not pulling golang.org/x/mod/semver because that
|
||||
// library rejects "15.4" (no patch) outright. Repology and endoflife are
|
||||
// happy to return such versions, so we have to handle them.
|
||||
|
||||
var numericPrefix = regexp.MustCompile(`^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?`)
|
||||
|
||||
// VersionParts captures up to three leading numeric components.
|
||||
type VersionParts struct {
|
||||
Major int
|
||||
Minor int
|
||||
Patch int
|
||||
HasMinor bool
|
||||
HasPatch bool
|
||||
Suffix string // anything after the numeric prefix, including separators
|
||||
}
|
||||
|
||||
func ParseVersion(s string) VersionParts {
|
||||
s = strings.TrimSpace(s)
|
||||
m := numericPrefix.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return VersionParts{Suffix: s}
|
||||
}
|
||||
out := VersionParts{Suffix: s[len(m[0]):]}
|
||||
if n, err := strconv.Atoi(m[1]); err == nil {
|
||||
out.Major = n
|
||||
}
|
||||
if m[2] != "" {
|
||||
out.HasMinor = true
|
||||
if n, err := strconv.Atoi(m[2]); err == nil {
|
||||
out.Minor = n
|
||||
}
|
||||
}
|
||||
if m[3] != "" {
|
||||
out.HasPatch = true
|
||||
if n, err := strconv.Atoi(m[3]); err == nil {
|
||||
out.Patch = n
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CompareVersions returns:
|
||||
// -1 if a < b
|
||||
// 0 if a == b (or both unparseable and equal)
|
||||
// 1 if a > b
|
||||
//
|
||||
// The leading numeric segments win. When those tie, the suffix is compared
|
||||
// lexicographically — imperfect (a "release" sorts before "rc"), but
|
||||
// suitable for drift detection: equal-or-not-equal is what the gate
|
||||
// actually needs.
|
||||
func CompareVersions(a, b string) int {
|
||||
pa, pb := ParseVersion(a), ParseVersion(b)
|
||||
if pa.Major != pb.Major {
|
||||
if pa.Major < pb.Major {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if pa.Minor != pb.Minor {
|
||||
if pa.Minor < pb.Minor {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if pa.Patch != pb.Patch {
|
||||
if pa.Patch < pb.Patch {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if pa.Suffix == pb.Suffix {
|
||||
return 0
|
||||
}
|
||||
// SemVer convention: release > prerelease. Empty suffix beats any non-empty.
|
||||
if pa.Suffix == "" {
|
||||
return 1
|
||||
}
|
||||
if pb.Suffix == "" {
|
||||
return -1
|
||||
}
|
||||
if pa.Suffix < pb.Suffix {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// ClassifyDrift returns the severity label for two known versions. When the
|
||||
// majors differ it's "major"; minors differ → "minor"; patches differ →
|
||||
// "patch"; otherwise "metadata" (build/rc/suffix shift). Caller may override
|
||||
// with "eol" when an EOL date has passed.
|
||||
func ClassifyDrift(from, to string) string {
|
||||
pf, pt := ParseVersion(from), ParseVersion(to)
|
||||
if pf.Major != pt.Major {
|
||||
return "major"
|
||||
}
|
||||
if pf.Minor != pt.Minor {
|
||||
return "minor"
|
||||
}
|
||||
if pf.Patch != pt.Patch {
|
||||
return "patch"
|
||||
}
|
||||
return "metadata"
|
||||
}
|
||||
66
server/internal/services/upstream/version_test.go
Normal file
66
server/internal/services/upstream/version_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package upstream
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
major int
|
||||
minor int
|
||||
patch int
|
||||
hasMinor bool
|
||||
hasPatch bool
|
||||
}{
|
||||
{"15.4", 15, 4, 0, true, false},
|
||||
{"v1.27.3", 1, 27, 3, true, true},
|
||||
{"1.0.0-rc1+meta", 1, 0, 0, true, true},
|
||||
{"20231130-1.fc40", 20231130, 0, 0, false, false},
|
||||
{"3", 3, 0, 0, false, false},
|
||||
{"", 0, 0, 0, false, false},
|
||||
{"nginx", 0, 0, 0, false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := ParseVersion(c.in)
|
||||
if got.Major != c.major || got.Minor != c.minor || got.Patch != c.patch ||
|
||||
got.HasMinor != c.hasMinor || got.HasPatch != c.hasPatch {
|
||||
t.Errorf("ParseVersion(%q) = %+v, want major=%d minor=%d patch=%d", c.in, got, c.major, c.minor, c.patch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"15.4", "15.5", -1},
|
||||
{"15.5", "15.4", 1},
|
||||
{"15.4", "15.4", 0},
|
||||
{"v1.0.0", "1.0.0", 0},
|
||||
{"2.0.0", "1.99.99", 1},
|
||||
{"1.0.0-rc1", "1.0.0-rc2", -1},
|
||||
{"1.0.0", "1.0.0-rc1", 1}, // empty suffix sorts before non-empty
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := CompareVersions(c.a, c.b); got != c.want {
|
||||
t.Errorf("CompareVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyDrift(t *testing.T) {
|
||||
cases := []struct {
|
||||
from, to string
|
||||
want string
|
||||
}{
|
||||
{"14.5", "15.0", "major"},
|
||||
{"15.3", "15.4", "minor"},
|
||||
{"1.27.2", "1.27.3", "patch"},
|
||||
{"1.0.0-rc1", "1.0.0-rc2", "metadata"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClassifyDrift(c.from, c.to); got != c.want {
|
||||
t.Errorf("ClassifyDrift(%q, %q) = %q, want %q", c.from, c.to, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import TokenManagement from '@/pages/TokenManagement';
|
|||
import RateLimiting from '@/pages/RateLimiting';
|
||||
import AgentManagement from '@/pages/settings/AgentManagement';
|
||||
import MaintenanceWindows from '@/pages/settings/MaintenanceWindows';
|
||||
import UpstreamTracking from '@/pages/settings/UpstreamTracking';
|
||||
import Login from '@/pages/Login';
|
||||
import Setup from '@/pages/Setup';
|
||||
import { WelcomeChecker } from '@/components/WelcomeChecker';
|
||||
|
|
@ -140,6 +141,7 @@ const App: React.FC = () => {
|
|||
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
|
||||
<Route path="/settings/agents" element={<AgentManagement />} />
|
||||
<Route path="/settings/maintenance-windows" element={<MaintenanceWindows />} />
|
||||
<Route path="/settings/upstream" element={<UpstreamTracking />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
|
|
|||
151
web/src/components/AttentionPanel.tsx
Normal file
151
web/src/components/AttentionPanel.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle,
|
||||
AlertOctagon,
|
||||
XCircle,
|
||||
WifiOff,
|
||||
GitBranch,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { useDashboardStats } from '@/hooks/useStats';
|
||||
import { useDriftedSoftware, useRecentDriftEvents } from '@/hooks/useUpstream';
|
||||
|
||||
// Severity ranks: eol > major > failed > offline > minor > patch > metadata.
|
||||
// Used to order the feed so the most-urgent thing is on top.
|
||||
const SEVERITY_RANK: Record<string, number> = {
|
||||
eol: 100,
|
||||
failed: 80,
|
||||
major: 70,
|
||||
offline: 60,
|
||||
minor: 40,
|
||||
patch: 20,
|
||||
metadata: 10,
|
||||
};
|
||||
|
||||
interface AlertItem {
|
||||
key: string;
|
||||
severity: string; // 'eol' | 'major' | 'failed' | 'offline' | ...
|
||||
title: string;
|
||||
detail?: string;
|
||||
href?: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const AttentionPanel: React.FC = () => {
|
||||
const { data: stats } = useDashboardStats();
|
||||
const { data: drifted } = useDriftedSoftware();
|
||||
const { data: driftEvents } = useRecentDriftEvents();
|
||||
|
||||
const alerts: AlertItem[] = [];
|
||||
|
||||
// 1. Offline / silent agents
|
||||
if (stats && stats.offline_agents > 0) {
|
||||
alerts.push({
|
||||
key: 'offline-agents',
|
||||
severity: 'offline',
|
||||
title: `${stats.offline_agents} agent${stats.offline_agents === 1 ? '' : 's'} offline`,
|
||||
detail: `${stats.online_agents} of ${stats.total_agents} online`,
|
||||
href: '/agents?status=offline',
|
||||
icon: WifiOff,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Failed updates
|
||||
if (stats && stats.failed_updates > 0) {
|
||||
alerts.push({
|
||||
key: 'failed-updates',
|
||||
severity: 'failed',
|
||||
title: `${stats.failed_updates} failed update${stats.failed_updates === 1 ? '' : 's'}`,
|
||||
detail: 'Inspect agent logs to determine cause',
|
||||
href: '/updates?status=failed',
|
||||
icon: XCircle,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. EOL software (drifted + past EOL date)
|
||||
if (drifted) {
|
||||
const eolPassed = drifted.filter((s) => s.eol_at && new Date(s.eol_at) < new Date());
|
||||
for (const s of eolPassed) {
|
||||
alerts.push({
|
||||
key: `eol-${s.id}`,
|
||||
severity: 'eol',
|
||||
title: `${s.name} is past upstream EOL`,
|
||||
detail: `deployed ${s.current_version ?? '?'} — upstream ${s.latest_version ?? '?'} (EOL ${s.eol_at?.slice(0, 10)})`,
|
||||
href: '/settings/upstream',
|
||||
icon: AlertOctagon,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Recent drift events (latest_version moved on something we're tracking)
|
||||
if (driftEvents) {
|
||||
for (const ev of driftEvents.slice(0, 5)) {
|
||||
if (ev.drift_severity === 'metadata') continue;
|
||||
alerts.push({
|
||||
key: `drift-${ev.id}`,
|
||||
severity: ev.drift_severity,
|
||||
title: `Upstream moved: ${ev.from_version ?? '?'} → ${ev.to_version ?? '?'}`,
|
||||
detail: ev.note ?? `${ev.drift_severity} version change observed ${new Date(ev.observed_at).toLocaleDateString()}`,
|
||||
href: '/settings/upstream',
|
||||
icon: GitBranch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
alerts.sort((a, b) => (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0));
|
||||
|
||||
if (alerts.length === 0) {
|
||||
return null; // calm dashboards stay calm
|
||||
}
|
||||
|
||||
const top = alerts.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-5 mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||
<h2 className="text-base font-semibold text-amber-900">Attention</h2>
|
||||
<span className="text-xs text-amber-700">({alerts.length})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{top.map((a) => {
|
||||
const Icon = a.icon;
|
||||
const eol = a.severity === 'eol';
|
||||
return (
|
||||
<li key={a.key}>
|
||||
<Link
|
||||
to={a.href ?? '#'}
|
||||
className={`flex items-start gap-3 p-2 rounded hover:bg-amber-100 ${eol ? 'bg-red-50' : ''}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${eol ? 'text-red-600' : 'text-amber-700'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${eol ? 'text-red-900' : 'text-amber-900'}`}>
|
||||
{a.title}
|
||||
</p>
|
||||
{a.detail && (
|
||||
<p className={`text-xs ${eol ? 'text-red-700' : 'text-amber-700'} truncate`}>
|
||||
{a.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className={`w-4 h-4 flex-shrink-0 mt-1 ${eol ? 'text-red-400' : 'text-amber-400'}`} />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{alerts.length > top.length && (
|
||||
<p className="text-xs text-amber-700 mt-2">
|
||||
+ {alerts.length - top.length} more — open the linked pages for details.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttentionPanel;
|
||||
|
|
@ -13,6 +13,7 @@ import { useDashboardStats } from '@/hooks/useStats';
|
|||
|
||||
import { useServerKeySecurity } from '@/hooks/useSecurity';
|
||||
import StackDriftPanel from '@/components/StackDriftPanel';
|
||||
import AttentionPanel from '@/components/AttentionPanel';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { data: stats, isPending, error } = useDashboardStats();
|
||||
|
|
@ -99,6 +100,9 @@ const Dashboard: React.FC = () => {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* Aggregated attention surface — offline agents, failed updates, EOL drift, upstream movement */}
|
||||
<AttentionPanel />
|
||||
|
||||
{/* Important Messages / Security Alert */}
|
||||
{serverKeySecurity && !serverKeySecurity.has_private_key && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-3 rounded-lg relative mb-8" role="alert">
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import React from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Shield,
|
||||
Server,
|
||||
Settings as SettingsIcon,
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
Activity,
|
||||
Clock,
|
||||
GitBranch,
|
||||
} from 'lucide-react';
|
||||
import { useSettingsStore } from '@/lib/store';
|
||||
import { useTimezones, useTimezone, useUpdateTimezone } from '../hooks/useSettings';
|
||||
|
|
@ -77,15 +77,6 @@ const Settings: React.FC = () => {
|
|||
<p className="text-sm text-gray-600 mt-1">Configure API rate limits</p>
|
||||
</Link>
|
||||
|
||||
<div className="p-6 bg-gray-50 border border-gray-200 rounded-lg opacity-60">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Server className="w-8 h-8 text-gray-400" />
|
||||
<ArrowRight className="w-5 h-5 text-gray-300" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-500">System Configuration</h3>
|
||||
<p className="text-sm text-gray-400 mt-1">Coming soon</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/settings/agents"
|
||||
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-purple-300 hover:shadow-sm transition-all"
|
||||
|
|
@ -109,6 +100,18 @@ const Settings: React.FC = () => {
|
|||
<h3 className="font-semibold text-gray-900">Maintenance Windows</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">Schedule update installation windows</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/settings/upstream"
|
||||
className="block p-6 bg-white border border-gray-200 rounded-lg hover:border-teal-300 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<GitBranch className="w-8 h-8 text-teal-600" />
|
||||
<ArrowRight className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900">Upstream Tracking</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">Compare deployed versions to canonical upstream releases</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Overview Statistics */}
|
||||
|
|
@ -250,7 +253,7 @@ const Settings: React.FC = () => {
|
|||
autoRefresh ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className={`translate-x-${autoRefresh ? '5' : '0'} inline-block h-5 w-5 transform rounded-full bg-white transition`} />
|
||||
<span className={`${autoRefresh ? 'translate-x-5' : 'translate-x-0'} inline-block h-5 w-5 transform rounded-full bg-white transition`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -274,32 +277,6 @@ const Settings: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Implementation Status */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold text-yellow-800 mb-4">Implementation Status</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-yellow-800 mb-3">✅ Implemented Features</h3>
|
||||
<ul className="space-y-1 text-sm text-yellow-700">
|
||||
<li>• Registration token management (full CRUD)</li>
|
||||
<li>• API rate limiting configuration</li>
|
||||
<li>• Real-time usage monitoring</li>
|
||||
<li>• User preferences (timezone, dashboard)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-yellow-800 mb-3">🚧 Planned Features</h3>
|
||||
<ul className="space-y-1 text-sm text-yellow-700">
|
||||
<li>• System configuration management</li>
|
||||
<li>• Integration with third-party services</li>
|
||||
<li>• Persistent settings storage</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-yellow-600">
|
||||
This settings page reflects the current state of the RedFlag backend API.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
333
web/src/pages/settings/UpstreamTracking.tsx
Normal file
333
web/src/pages/settings/UpstreamTracking.tsx
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
GitBranch,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
AlertOctagon,
|
||||
CheckCircle,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useTrackedSoftware,
|
||||
useAddTrackedSoftware,
|
||||
useRemoveTrackedSoftware,
|
||||
useSyncTrackedSoftware,
|
||||
} from '@/hooks/useUpstream';
|
||||
import { CreateTrackedSoftwareRequest, UpstreamSource } from '@/types';
|
||||
|
||||
// Curated seeds for "common stack" one-click adds. (source, source_ref) tuples
|
||||
// follow the same mapping the API expects; Repology and endoflife.date are
|
||||
// the only two adapters bundled today, so all entries use one or the other.
|
||||
//
|
||||
// To extend: just append. The set is deliberately small — operators add
|
||||
// their own from there, and we don't ship an opinion about "what every server
|
||||
// needs to track."
|
||||
const COMMON_SEEDS: CreateTrackedSoftwareRequest[] = [
|
||||
{ name: 'PostgreSQL', ecosystem: 'system', source: 'endoflife', source_ref: 'postgresql' },
|
||||
{ name: 'nginx', ecosystem: 'system', source: 'endoflife', source_ref: 'nginx' },
|
||||
{ name: 'Node.js', ecosystem: 'language', source: 'endoflife', source_ref: 'nodejs' },
|
||||
{ name: 'Python', ecosystem: 'language', source: 'endoflife', source_ref: 'python' },
|
||||
{ name: 'Redis', ecosystem: 'system', source: 'endoflife', source_ref: 'redis' },
|
||||
{ name: 'Docker', ecosystem: 'system', source: 'endoflife', source_ref: 'docker-engine' },
|
||||
{ name: 'Go', ecosystem: 'language', source: 'endoflife', source_ref: 'go' },
|
||||
{ name: 'Kubernetes', ecosystem: 'system', source: 'endoflife', source_ref: 'kubernetes' },
|
||||
{ name: 'Ubuntu', ecosystem: 'system', source: 'endoflife', source_ref: 'ubuntu' },
|
||||
{ name: 'Debian', ecosystem: 'system', source: 'endoflife', source_ref: 'debian' },
|
||||
];
|
||||
|
||||
const sourceLabel = (s: UpstreamSource): string => {
|
||||
switch (s) {
|
||||
case 'repology': return 'Repology';
|
||||
case 'endoflife': return 'endoflife.date';
|
||||
case 'anitya': return 'Anitya';
|
||||
case 'github': return 'GitHub Releases';
|
||||
case 'npm': return 'npm';
|
||||
case 'pypi': return 'PyPI';
|
||||
}
|
||||
};
|
||||
|
||||
const UpstreamTracking: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { data, isPending } = useTrackedSoftware();
|
||||
const add = useAddTrackedSoftware();
|
||||
const remove = useRemoveTrackedSoftware();
|
||||
const sync = useSyncTrackedSoftware();
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [form, setForm] = useState<CreateTrackedSoftwareRequest>({
|
||||
name: '',
|
||||
ecosystem: 'system',
|
||||
source: 'endoflife',
|
||||
source_ref: '',
|
||||
current_version: '',
|
||||
});
|
||||
|
||||
const software = data?.software ?? [];
|
||||
const availableSources = (data?.sources ?? ['repology', 'endoflife']) as string[];
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!form.name || !form.source_ref) return;
|
||||
add.mutate({ ...form, current_version: form.current_version || undefined }, {
|
||||
onSuccess: () => {
|
||||
setForm({ name: '', ecosystem: 'system', source: 'endoflife', source_ref: '', current_version: '' });
|
||||
setShowAddForm(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSeed = (seed: CreateTrackedSoftwareRequest) => {
|
||||
add.mutate(seed);
|
||||
};
|
||||
|
||||
const trackedRefs = new Set(software.map((s) => `${s.source}:${s.source_ref}`));
|
||||
const unseededCommon = COMMON_SEEDS.filter((s) => !trackedRefs.has(`${s.source}:${s.source_ref}`));
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-4"
|
||||
>
|
||||
← Back to Settings
|
||||
</button>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Upstream Version Tracking</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Compare your deployed versions against canonical upstream releases. Pluggable sources:
|
||||
Repology (cross-distro) and endoflife.date (release lines + EOL dates).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm((v) => !v)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Track Software
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Track a new piece of software</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Display name</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="e.g. PostgreSQL"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ecosystem</label>
|
||||
<select
|
||||
value={form.ecosystem}
|
||||
onChange={(e) => setForm({ ...form, ecosystem: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="system">system</option>
|
||||
<option value="container">container</option>
|
||||
<option value="language">language</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Source</label>
|
||||
<select
|
||||
value={form.source}
|
||||
onChange={(e) => setForm({ ...form, source: e.target.value as UpstreamSource })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{availableSources.map((s) => (
|
||||
<option key={s} value={s}>{sourceLabel(s as UpstreamSource)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Source reference
|
||||
<span className="ml-1 text-xs text-gray-500">(project slug used by the source)</span>
|
||||
</label>
|
||||
<input
|
||||
value={form.source_ref}
|
||||
onChange={(e) => setForm({ ...form, source_ref: e.target.value })}
|
||||
placeholder="e.g. postgresql"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Current deployed version
|
||||
<span className="ml-1 text-xs text-gray-500">(optional — operator-supplied for now)</span>
|
||||
</label>
|
||||
<input
|
||||
value={form.current_version || ''}
|
||||
onChange={(e) => setForm({ ...form, current_version: e.target.value })}
|
||||
placeholder="e.g. 15.4"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={!form.name || !form.source_ref || add.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{add.isPending ? 'Adding...' : 'Track'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unseededCommon.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-blue-900 mb-1">Common stack — one-click add</h3>
|
||||
<p className="text-sm text-blue-700">
|
||||
Track popular projects with their endoflife.date slugs already mapped. Click an item to track it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{unseededCommon.map((s) => (
|
||||
<button
|
||||
key={`${s.source}:${s.source_ref}`}
|
||||
onClick={() => handleSeed(s)}
|
||||
disabled={add.isPending}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 bg-white border border-blue-300 text-blue-700 rounded hover:bg-blue-100 text-sm disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Tracked software ({software.length})</h2>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : software.length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-600">
|
||||
<GitBranch className="w-12 h-12 mx-auto text-gray-400 mb-3" />
|
||||
<p>Nothing tracked yet. Use the form above or the common-stack chips to start.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Software</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Source</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Current</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Latest</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">EOL</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Last sync</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{software.map((s) => {
|
||||
const drift =
|
||||
s.current_version && s.latest_version && s.current_version !== s.latest_version;
|
||||
const eolPassed = s.eol_at && new Date(s.eol_at) < new Date();
|
||||
return (
|
||||
<tr key={s.id} className={eolPassed ? 'bg-red-50' : drift ? 'bg-yellow-50' : ''}>
|
||||
<td className="px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{eolPassed && <AlertOctagon className="w-4 h-4 text-red-600" />}
|
||||
{!drift && !eolPassed && s.latest_version && (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
)}
|
||||
<span className="font-medium text-gray-900">{s.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{s.ecosystem}</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-gray-600">
|
||||
{sourceLabel(s.source)}
|
||||
<div className="text-xs text-gray-400 font-mono">{s.source_ref}</div>
|
||||
</td>
|
||||
<td className="px-6 py-3 font-mono text-sm">{s.current_version ?? <span className="text-gray-400">—</span>}</td>
|
||||
<td className="px-6 py-3 font-mono text-sm">{s.latest_version ?? <span className="text-gray-400">—</span>}</td>
|
||||
<td className="px-6 py-3 text-sm">
|
||||
{s.eol_at ? (
|
||||
<span className={eolPassed ? 'text-red-700 font-medium' : 'text-gray-700'}>
|
||||
{new Date(s.eol_at).toISOString().slice(0, 10)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-xs text-gray-500">
|
||||
{s.last_synced_at ? new Date(s.last_synced_at).toLocaleString() : (
|
||||
<span className="text-gray-400">never</span>
|
||||
)}
|
||||
{s.last_error && (
|
||||
<div className="text-red-600 truncate max-w-xs" title={s.last_error}>
|
||||
⚠ {s.last_error}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right space-x-2 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => sync.mutate(s.id)}
|
||||
disabled={sync.isPending}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 rounded"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${sync.isPending ? 'animate-spin' : ''}`} />
|
||||
Sync
|
||||
</button>
|
||||
<a
|
||||
href={s.source === 'repology'
|
||||
? `https://repology.org/project/${s.source_ref}/versions`
|
||||
: `https://endoflife.date/${s.source_ref}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 rounded"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Source
|
||||
</a>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Stop tracking ${s.name}? Drift events are kept for audit.`)) {
|
||||
remove.mutate(s.id);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:bg-red-50 rounded"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpstreamTracking;
|
||||
Loading…
Reference in a new issue