Live app status orbs with uptime dashboard links

Replace text status pills on the dashboard's Applications cards with
pulsating colored orbs (tooltip on hover). When an application has an
uptime dashboard URL configured, clicking the orb opens it in a new tab.

Adds an `uptime_url` field to the application settings (admin form +
backend persistence + GET-time migration for existing rows). External
URLs entered without a scheme are normalized to https:// so they don't
resolve as relative paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 15:38:59 -07:00
parent 9b076de65a
commit c52f2cd488
5 changed files with 77 additions and 33 deletions

View File

@@ -111,7 +111,7 @@ class Setting(db.Model):
# banner_messages removed — replaced by announcements table + MQTT
"downtime_applications": "[]",
"support_resources": '[{"title":"Documentation","url":"#"},{"title":"Submit a Ticket","url":"#"},{"title":"FAQ","url":"#"}]',
"applications": '[{"title":"Artifactory","description":"Artifact repository and package management","icon":"","app_url":"#","status_key":"Artifactory"},{"title":"Bitbucket","description":"Git repository hosting and collaboration","icon":"","app_url":"#","status_key":"Bitbucket"},{"title":"OpenShift","description":"Container platform and Kubernetes management","icon":"","app_url":"#","status_key":"OpenShift"},{"title":"SRM","description":"Software Risk Manager — application security testing","icon":"","app_url":"#","status_key":"SRM"},{"title":"Black Duck Binary Analysis","description":"Binary composition analysis and vulnerability detection","icon":"","app_url":"#","status_key":"BDBA"},{"title":"Coverity","description":"Static application security testing (SAST)","icon":"","app_url":"#","status_key":"Coverity"},{"title":"SwaggerHub","description":"API design, documentation, and collaboration","icon":"","app_url":"#","status_key":"SwaggerHub"}]',
"applications": '[{"title":"Artifactory","description":"Artifact repository and package management","icon":"","app_url":"#","uptime_url":"","status_key":"Artifactory"},{"title":"Bitbucket","description":"Git repository hosting and collaboration","icon":"","app_url":"#","uptime_url":"","status_key":"Bitbucket"},{"title":"OpenShift","description":"Container platform and Kubernetes management","icon":"","app_url":"#","uptime_url":"","status_key":"OpenShift"},{"title":"SRM","description":"Software Risk Manager — application security testing","icon":"","app_url":"#","uptime_url":"","status_key":"SRM"},{"title":"Black Duck Binary Analysis","description":"Binary composition analysis and vulnerability detection","icon":"","app_url":"#","uptime_url":"","status_key":"BDBA"},{"title":"Coverity","description":"Static application security testing (SAST)","icon":"","app_url":"#","uptime_url":"","status_key":"Coverity"},{"title":"SwaggerHub","description":"API design, documentation, and collaboration","icon":"","app_url":"#","uptime_url":"","status_key":"SwaggerHub"}]',
}
@classmethod

View File

@@ -529,6 +529,7 @@ def get_applications():
app.pop("status_url")
if not app.get("status_key"):
app["status_key"] = app.get("title", "")
app.setdefault("uptime_url", "")
return jsonify({"applications": data}), 200
@@ -590,6 +591,9 @@ def update_applications():
"description": a["description"].strip(),
"icon": a.get("icon", "").strip() if isinstance(a.get("icon"), str) else "",
"app_url": a["app_url"].strip(),
"uptime_url": a.get("uptime_url", "").strip()
if isinstance(a.get("uptime_url"), str)
else "",
"status_key": a.get("status_key", "").strip()
if isinstance(a.get("status_key"), str)
else "",

View File

@@ -278,6 +278,7 @@ export interface ApplicationEntry {
description: string;
icon: string; // base64 data URL
app_url: string;
uptime_url: string;
status_key: string;
}

View File

@@ -653,7 +653,7 @@ function ApplicationsManager() {
function addApplication() {
setDraft((prev) => [
...prev,
{ title: "", description: "", icon: "", app_url: "", status_key: "" },
{ title: "", description: "", icon: "", app_url: "", uptime_url: "", status_key: "" },
]);
setDirty(true);
}
@@ -819,9 +819,9 @@ function ApplicationsManager() {
<Trash2 className="size-3.5" />
</Button>
</div>
{/* Row 2: app URL | status key */}
{/* Row 2: app URL | uptime URL | status key */}
<div className="flex items-center gap-2">
<div className="flex-1">
<div className="flex-1 min-w-0">
<label className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">App URL</label>
<Input
value={app.app_url}
@@ -831,6 +831,16 @@ function ApplicationsManager() {
className="h-7 text-sm font-mono"
/>
</div>
<div className="flex-1 min-w-0">
<label className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Uptime Dashboard URL</label>
<Input
value={app.uptime_url ?? ""}
onChange={(e) =>
updateApplication(i, "uptime_url", e.target.value)
}
className="h-7 text-sm font-mono"
/>
</div>
<div className="w-40 shrink-0">
<label className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">MQTT Key</label>
<Input

View File

@@ -22,6 +22,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/* ------------------------------------------------------------------ */
@@ -58,37 +59,64 @@ function CopyEmailButton({ email }: { email: string }) {
/* Status pill for application cards */
/* ------------------------------------------------------------------ */
const STATUS_PILL_COLORS: Record<string, { bg: string; text: string }> = {
green: {
bg: "bg-emerald-50 dark:bg-emerald-950/30",
text: "text-emerald-700 dark:text-emerald-400",
},
amber: {
bg: "bg-amber-50 dark:bg-amber-950/30",
text: "text-amber-700 dark:text-amber-400",
},
red: {
bg: "bg-red-50 dark:bg-red-950/30",
text: "text-red-700 dark:text-red-400",
},
gray: {
bg: "bg-gray-50 dark:bg-gray-900/30",
text: "text-gray-600 dark:text-gray-400",
},
const STATUS_ORB_COLORS: Record<string, string> = {
green: "bg-emerald-500",
amber: "bg-amber-500",
red: "bg-red-500",
gray: "bg-gray-400",
};
function StatusPill({ status, color }: { status: string; color: string }) {
const colors = STATUS_PILL_COLORS[color] ?? STATUS_PILL_COLORS.gray;
function normalizeExternalUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) return trimmed;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || trimmed.startsWith("//")) {
return trimmed;
}
return `https://${trimmed}`;
}
function StatusPill({
status,
color,
uptimeUrl,
}: {
status: string;
color: string;
uptimeUrl?: string;
}) {
const dotColor = STATUS_ORB_COLORS[color] ?? STATUS_ORB_COLORS.gray;
const target = uptimeUrl ? normalizeExternalUrl(uptimeUrl) : "";
const tooltipText = target ? `${status} — view uptime dashboard` : status;
const open = (e: React.SyntheticEvent) => {
if (!target) return;
e.preventDefault();
e.stopPropagation();
window.open(target, "_blank", "noopener,noreferrer");
};
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
colors.bg,
colors.text
)}
>
{status}
</span>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<span
role={target ? "link" : undefined}
tabIndex={target ? 0 : undefined}
onClick={open}
onKeyDown={(e) => {
if (target && (e.key === "Enter" || e.key === " ")) open(e);
}}
className={cn(
"relative inline-flex size-2.5",
target ? "cursor-pointer" : "cursor-default"
)}
aria-label={tooltipText}
>
<span className={cn("absolute inline-flex h-full w-full animate-ping rounded-full opacity-60", dotColor)} />
<span className={cn("relative inline-flex size-2.5 rounded-full", dotColor)} />
</span>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -327,7 +355,7 @@ function ApplicationsSection() {
return (
<a
key={app.title}
href={app.app_url}
href={normalizeExternalUrl(app.app_url)}
target="_blank"
rel="noopener noreferrer"
className="block"
@@ -340,6 +368,7 @@ function ApplicationsSection() {
<StatusPill
status={appStatus.status}
color={appStatus.color}
uptimeUrl={app.uptime_url || undefined}
/>
)}
</div>