Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-05-23 15:18:47 -04:00
commit bcd67e04bf
8 changed files with 688 additions and 49 deletions

View file

@ -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"
}

View 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"
}

View 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)
}
}
}

View file

@ -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>

View 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;

View file

@ -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">

View file

@ -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>
);
};

View 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;