Add KC inbound sync worker and settings tab badges
Periodic background thread pulls users and group memberships from Keycloak into the local DB every 30s (configurable via admin settings). Closes the gap where users added directly in KC didn't appear in the app until a manual sync. Also adds count badges to the Keycloak and Feedback tabs on the admin settings page so issues are immediately visible. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
89
backend/app/kc_inbound_sync.py
Normal file
89
backend/app/kc_inbound_sync.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
KC Inbound Sync Worker — background daemon thread that periodically pulls
|
||||
user and group membership state from Keycloak into the local database.
|
||||
|
||||
The outbound KC queue (kc_worker.py) pushes app changes TO Keycloak.
|
||||
This worker closes the loop by pulling KC state BACK, catching any
|
||||
changes made directly in the KC admin console or by other systems.
|
||||
|
||||
Interval is configurable via the ``kc_inbound_sync_minutes`` setting
|
||||
(default 10).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from app.extensions import db
|
||||
from app.keycloak import keycloak, KeycloakError
|
||||
from app.keycloak_sync import sync_users_from_keycloak, sync_permissions_from_keycloak
|
||||
from app.settings.models import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_INTERVAL_SECONDS = 30
|
||||
|
||||
|
||||
class KcInboundSyncWorker:
|
||||
"""Background thread that syncs KC users and permissions on a timer."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = None
|
||||
|
||||
def start(self):
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, daemon=True, name="kc-inbound-sync"
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info("KC inbound sync worker started (default interval: %ds)", DEFAULT_INTERVAL_SECONDS)
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=5)
|
||||
|
||||
def _get_interval_seconds(self):
|
||||
try:
|
||||
with self.app.app_context():
|
||||
val = Setting.get("kc_inbound_sync_seconds")
|
||||
if val is not None:
|
||||
return max(5, int(val))
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_INTERVAL_SECONDS
|
||||
|
||||
def _run(self):
|
||||
while not self._stop_event.is_set():
|
||||
interval = self._get_interval_seconds()
|
||||
if self._stop_event.wait(timeout=interval):
|
||||
break
|
||||
|
||||
try:
|
||||
with self.app.app_context():
|
||||
if not keycloak.enabled:
|
||||
continue
|
||||
|
||||
user_result = sync_users_from_keycloak(keycloak)
|
||||
perm_result = sync_permissions_from_keycloak(keycloak)
|
||||
|
||||
changes = (
|
||||
user_result.get("created", 0)
|
||||
+ user_result.get("updated", 0)
|
||||
+ perm_result.get("permissions_created", 0)
|
||||
)
|
||||
if changes:
|
||||
logger.info(
|
||||
"KC inbound sync: users(created=%d, updated=%d), permissions(projects=%d, rows=%d)",
|
||||
user_result.get("created", 0),
|
||||
user_result.get("updated", 0),
|
||||
perm_result.get("projects_synced", 0),
|
||||
perm_result.get("permissions_created", 0),
|
||||
)
|
||||
else:
|
||||
logger.debug("KC inbound sync: no changes")
|
||||
except KeycloakError as exc:
|
||||
logger.warning("KC inbound sync failed (will retry): %s", exc)
|
||||
except Exception:
|
||||
logger.exception("KC inbound sync unexpected error")
|
||||
@@ -26,6 +26,10 @@ with app.app_context():
|
||||
worker.recover_stale()
|
||||
worker.start()
|
||||
|
||||
from app.kc_inbound_sync import KcInboundSyncWorker
|
||||
inbound_sync = KcInboundSyncWorker(app)
|
||||
inbound_sync.start()
|
||||
|
||||
# Start downtime reminder worker (background daemon thread)
|
||||
from app.downtime_reminder import DowntimeReminderWorker
|
||||
reminder_worker = DowntimeReminderWorker(app)
|
||||
|
||||
@@ -24,6 +24,7 @@ export const FORM_NUMERIC_KEYS = [
|
||||
"verification_interval_days",
|
||||
"downtime_reminder_max_days",
|
||||
"unsponsored_deletion_days",
|
||||
"kc_inbound_sync_seconds",
|
||||
"billing_day_of_month",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { KeycloakDiscrepancySection } from "@/components/admin/keycloak-discrepancy-section";
|
||||
import { KcSyncErrorsSection } from "@/components/admin/kc-sync-errors-section";
|
||||
import { useKcSyncErrorCount } from "@/hooks/use-kc-sync-errors";
|
||||
import { AgencyServiceManager } from "@/components/admin/agency-service-manager";
|
||||
import { CostCenterManager } from "@/components/admin/cost-center-manager";
|
||||
import { DowntimeApplicationsManager } from "@/components/admin/downtime-applications-manager";
|
||||
@@ -1331,6 +1332,8 @@ export default function AdminPage() {
|
||||
}, [setSearchParams]);
|
||||
|
||||
const { data: feedbackList, isLoading } = useFeedbackList();
|
||||
const { data: kcErrorCount } = useKcSyncErrorCount(true);
|
||||
const unreadFeedbackCount = feedbackList?.filter((f) => f.status === "unread").length ?? 0;
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const { data: detail } = useFeedbackDetail(selectedId);
|
||||
@@ -1361,8 +1364,22 @@ export default function AdminPage() {
|
||||
<TabsTrigger value="notifications">Notifications</TabsTrigger>
|
||||
<TabsTrigger value="content">Content</TabsTrigger>
|
||||
<TabsTrigger value="system">System</TabsTrigger>
|
||||
<TabsTrigger value="feedback">Feedback</TabsTrigger>
|
||||
<TabsTrigger value="keycloak">Keycloak</TabsTrigger>
|
||||
<TabsTrigger value="feedback" className="gap-1.5">
|
||||
Feedback
|
||||
{!!unreadFeedbackCount && (
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-blue-500 px-1.5 py-0.5 text-[10px] font-bold leading-none text-white">
|
||||
{unreadFeedbackCount > 99 ? "99+" : unreadFeedbackCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="keycloak" className="gap-1.5">
|
||||
Keycloak
|
||||
{!!kcErrorCount && (
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-bold leading-none text-white">
|
||||
{kcErrorCount > 99 ? "99+" : kcErrorCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="user-guide">User Guide</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -1524,6 +1541,9 @@ export default function AdminPage() {
|
||||
<SettingRow label="Auto-Archive Expired Items" description="Archive expired licenses/certs after this many days. 0 = disabled" htmlFor="auto-archive">
|
||||
<Input id="auto-archive" type="number" min={0} max={3650} className="w-24 h-8 text-sm" value={sf.form.auto_archive_days ?? ""} onChange={(e) => sf.updateField("auto_archive_days", e.target.value)} />
|
||||
</SettingRow>
|
||||
<SettingRow label="KC Inbound Sync" description="Seconds between pulling users and permissions from Keycloak" htmlFor="kc-inbound-sync">
|
||||
<Input id="kc-inbound-sync" type="number" min={5} max={86400} className="w-24 h-8 text-sm" value={sf.form.kc_inbound_sync_seconds ?? ""} onChange={(e) => sf.updateField("kc_inbound_sync_seconds", e.target.value)} placeholder="30" />
|
||||
</SettingRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user