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:
@@ -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
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -278,6 +278,7 @@ export interface ApplicationEntry {
|
||||
description: string;
|
||||
icon: string; // base64 data URL
|
||||
app_url: string;
|
||||
uptime_url: string;
|
||||
status_key: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user