feat: create ui to manage applications
Brijesh Wawdhane ops@brijesh.dev
Sat, 21 Dec 2024 04:51:55 +0530
8 files changed,
673 insertions(+),
312 deletions(-)
M
src/app/dashboard/api-keys/layout.js
→
src/app/dashboard/applications/layout.js
@@ -5,7 +5,7 @@ import Link from "next/link";
import Button from "@/components/shared/Button"; import { usePathname } from "next/navigation"; -export default function APIKeysLayout({ children }) { +export default function ApplicationsLayout({ children }) { const pathname = usePathname(); const { user } = useAuth();@@ -19,14 +19,16 @@ <div className="py-4">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="flex items-center justify-between"> <div> - <h1 className="text-2xl font-semibold text-gray-900">API Keys</h1> + <h1 className="text-2xl font-semibold text-gray-900"> + Applications + </h1> <p className="mt-3 text-sm text-gray-500"> - Manage your API keys for programmatic access to Argus. + Manage your applications and their logging configurations. </p> </div> - {pathname === "/dashboard/api-keys" && ( - <Link href="/dashboard/api-keys/new"> - <Button>Create API Key</Button> + {pathname === "/dashboard/applications" && ( + <Link href="/dashboard/applications/new"> + <Button>Create Application</Button> </Link> )} </div>
D
src/app/dashboard/api-keys/new/page.js
@@ -1,137 +0,0 @@
-"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import Button from "@/components/shared/Button"; -import Input from "@/components/shared/Input"; - -export default function NewAPIKeyPage() { - const router = useRouter(); - const [name, setName] = useState(""); - const [error, setError] = useState(""); - const [newKey, setNewKey] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - async function handleSubmit(e) { - e.preventDefault(); - setIsSubmitting(true); - setError(""); - - try { - const response = await fetch( - "http://localhost:8080/twirp/apikeys.APIKeysService/CreateAPIKey", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - token: localStorage.getItem("token"), - name: name, - expires_at: "", // Optional, you can add a date picker if needed - }), - }, - ); - - const data = await response.json(); - - // Check for Twirp error response - if (data.code || data.msg) { - throw new Error(data.msg || "Failed to create API key"); - } - - if (!data.key) { - throw new Error("No API key received"); - } - - setNewKey({ - key: data.key, - name: data.api_key.name, - created_at: data.api_key.created_at, - }); - } catch (error) { - setError(error.message || "Failed to create API key"); - console.error("Error:", error); - } finally { - setIsSubmitting(false); - } - } - - return ( - <> - {newKey ? ( - <div className="bg-white shadow rounded-lg p-6"> - <div className="space-y-4"> - <h2 className="text-lg font-medium text-gray-900"> - API Key Created: {newKey.name} - </h2> - <p className="text-sm text-gray-500"> - Please copy your API key now. You won't be able to see it again! - </p> - <div className="bg-gray-50 p-4 rounded-md"> - <code className="text-sm break-all">{newKey.key}</code> - </div> - <p className="text-xs text-gray-500"> - Created on: {new Date(newKey.created_at).toLocaleString()} - </p> - <div className="flex justify-end space-x-4"> - <Button - variant="secondary" - onClick={() => { - navigator.clipboard.writeText(newKey.key); - }} - > - Copy to Clipboard - </Button> - <Button onClick={() => router.push("/dashboard/api-keys")}> - Done - </Button> - </div> - </div> - </div> - ) : ( - <div className="bg-white shadow rounded-lg p-6"> - <form onSubmit={handleSubmit} className="space-y-6"> - <div> - <h2 className="text-lg font-medium text-gray-900"> - Create New API Key - </h2> - <p className="mt-1 text-sm text-gray-500"> - Give your API key a name to help you identify it later. - </p> - </div> - - {error && ( - <div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded"> - {error} - </div> - )} - - <div> - <Input - label="API Key Name" - value={name} - onChange={(e) => setName(e.target.value)} - placeholder="e.g., Development Server" - required - /> - </div> - - <div className="flex justify-end space-x-4"> - <Button - variant="secondary" - type="button" - onClick={() => router.push("/dashboard/api-keys")} - > - Cancel - </Button> - <Button type="submit" disabled={isSubmitting}> - {isSubmitting ? "Creating..." : "Create API Key"} - </Button> - </div> - </form> - </div> - )} - </> - ); -}
D
src/app/dashboard/api-keys/page.js
@@ -1,167 +0,0 @@
-"use client"; - -import { useState, useEffect } from "react"; -import Link from "next/link"; -import Button from "@/components/shared/Button"; - -export default function APIKeysPage() { - const [apiKeys, setApiKeys] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [deletingKeys, setDeletingKeys] = useState(new Set()); - - useEffect(() => { - fetchAPIKeys(); - }, []); - - async function fetchAPIKeys() { - try { - const response = await fetch( - "http://localhost:8080/twirp/apikeys.APIKeysService/ListAPIKeys", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - token: localStorage.getItem("token"), - }), - }, - ); - - if (!response.ok) throw new Error("Failed to fetch API keys"); - - const data = await response.json(); - setApiKeys(data.api_keys || []); - } catch (error) { - setError("Failed to load API keys"); - console.error("Error:", error); - } finally { - setIsLoading(false); - } - } - - if (isLoading) { - return ( - <div className="flex items-center justify-center h-64"> - <div className="text-gray-500">Loading API keys...</div> - </div> - ); - } - - if (error) { - return ( - <div className="rounded-lg bg-white shadow p-6"> - <div className="text-red-600">{error}</div> - </div> - ); - } - - async function handleDeleteKey(keyId) { - if ( - !confirm( - "Are you sure you want to delete this API key? This action cannot be undone.", - ) - ) { - return; - } - - setDeletingKeys((prev) => new Set([...prev, keyId])); - - try { - const response = await fetch( - `http://localhost:8080/twirp/apikeys.APIKeysService/DeleteAPIKey`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - token: localStorage.getItem("token"), - key_id: keyId, - }), - }, - ); - - if (!response.ok) throw new Error("Failed to delete API key: ", response); - - await fetchAPIKeys(); // Refresh the list - } catch (error) { - setError("Failed to delete API key"); - console.error("Error:", error); - } finally { - setDeletingKeys((prev) => { - const newSet = new Set(prev); - newSet.delete(keyId); - return newSet; - }); - } - } - - if (!apiKeys.length) { - return ( - <div className="rounded-lg bg-white shadow"> - <div className="px-4 py-12 my-0"> - <div className="text-center"> - <h3 className="mt-2 text-lg font-semibold text-gray-900"> - No API keys - </h3> - <p className="mt-1 text-sm text-gray-500"> - Create an API key to get started with programmatic access. - </p> - <div className="mt-6"> - <Link href="/dashboard/api-keys/new"> - <Button variant="secondary">Create API Key</Button> - </Link> - </div> - </div> - </div> - </div> - ); - } - - return ( - <> - <div className="bg-white shadow rounded-lg divide-y divide-gray-200"> - {apiKeys && - apiKeys.map((key) => ( - <APIKeyItem - key={key.id} - apiKey={key} - onDelete={() => handleDeleteKey(key.id)} - isDeleting={deletingKeys.has(key.id)} - /> - ))} - </div> - </> - ); -} - -function APIKeyItem({ apiKey, onDelete, isDeleting }) { - return ( - <div className="px-6 py-4"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-4"> - <h3 className="text-lg font-medium text-gray-900 capitalize w-48 truncate"> - {apiKey.name} - </h3> - <div className="mt-1 text-sm text-gray-500"> - Created on {new Date(apiKey.created_at).toLocaleDateString()} - </div> - {apiKey.last_used_at && ( - <div className="mt-1 text-sm text-gray-500"> - Last used on {new Date(apiKey.last_used_at).toLocaleDateString()} - </div> - )} - </div> - <button - onClick={onDelete} - disabled={isDeleting} - className={`text-sm text-red-600 hover:text-red-900 ${isDeleting ? "opacity-50 cursor-not-allowed" : ""}`} - > - {isDeleting ? "Deleting..." : "Delete"} - </button> - </div> - </div> - ); -}
A
src/app/dashboard/applications/[id]/edit/page.js
@@ -0,0 +1,226 @@
+"use client"; + +import { useState, useEffect, use } from "react"; +import { useRouter } from "next/navigation"; +import Button from "@/components/shared/Button"; +import Input from "@/components/shared/Input"; + +export default function EditApplicationPage({ params }) { + const resolvedParams = use(params); + const router = useRouter(); + const [application, setApplication] = useState(null); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [newKey, setNewKey] = useState(null); + + useEffect(() => { + fetchApplication(); + }, []); + + async function fetchApplication() { + try { + const response = await fetch( + "http://localhost:8080/twirp/applications.ApplicationsService/GetApplication", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: localStorage.getItem("token"), + application_id: resolvedParams.id, + }), + }, + ); + + const data = await response.json(); + if (data.code || data.msg) { + throw new Error(data.msg || "Failed to fetch application"); + } + + setApplication(data.application); + setName(data.application.name); + setDescription(data.application.description); + } catch (error) { + setError(error.message || "Failed to load application"); + console.error("Error:", error); + } finally { + setIsLoading(false); + } + } + + async function handleSubmit(e) { + e.preventDefault(); + setIsSubmitting(true); + setError(""); + + try { + const response = await fetch( + "http://localhost:8080/twirp/applications.ApplicationsService/UpdateApplication", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: localStorage.getItem("token"), + application_id: resolvedParams.id, + name, + description, + }), + }, + ); + + const data = await response.json(); + if (data.code || data.msg) { + throw new Error(data.msg || "Failed to update application"); + } + + router.push(`/dashboard/applications/${resolvedParams.id}`); + } catch (error) { + setError(error.message || "Failed to update application"); + console.error("Error:", error); + } finally { + setIsSubmitting(false); + } + } + + async function handleRegenerateKey() { + if ( + !confirm( + "Are you sure you want to regenerate the API key? The old key will stop working immediately.", + ) + ) { + return; + } + + try { + const response = await fetch( + "http://localhost:8080/twirp/applications.ApplicationsService/RegenerateKey", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: localStorage.getItem("token"), + application_id: resolvedParams.id, + }), + }, + ); + + const data = await response.json(); + if (data.code || data.msg) { + throw new Error(data.msg || "Failed to regenerate key"); + } + + setNewKey(data.key); + } catch (error) { + setError(error.message || "Failed to regenerate key"); + console.error("Error:", error); + } + } + + if (isLoading) return <div>Loading...</div>; + if (!application) return <div>Application not found</div>; + + return ( + <div className="space-y-6"> + {newKey && ( + <div className="bg-white shadow rounded-lg p-6 mb-6"> + <h3 className="text-lg font-medium text-gray-900 mb-4"> + New API Key Generated + </h3> + <p className="text-sm text-gray-500 mb-4"> + Please copy your new API key now. You won't be able to see it again! + </p> + <div className="bg-gray-50 p-4 rounded-md mb-4"> + <code className="text-sm break-all">{newKey}</code> + </div> + <Button + variant="secondary" + onClick={() => { + navigator.clipboard.writeText(newKey); + }} + > + Copy to Clipboard + </Button> + </div> + )} + + <div className="bg-white shadow rounded-lg p-6"> + <form onSubmit={handleSubmit} className="space-y-6"> + <div> + <h2 className="text-lg font-medium text-gray-900"> + Edit Application + </h2> + </div> + + {error && ( + <div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded"> + {error} + </div> + )} + + <div className="space-y-4"> + <Input + label="Application Name" + value={name} + onChange={(e) => setName(e.target.value)} + required + /> + + <div> + <label className="block text-sm font-medium text-gray-700"> + Description + </label> + <textarea + value={description} + onChange={(e) => setDescription(e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + rows={3} + /> + </div> + </div> + + <div className="border-t border-gray-200 pt-6"> + <div className="flex justify-between items-center"> + <div> + <h3 className="text-sm font-medium text-gray-900">API Key</h3> + <p className="mt-1 text-sm text-gray-500"> + Generate a new API key if the current one has been + compromised. + </p> + </div> + <Button + type="button" + variant="secondary" + onClick={handleRegenerateKey} + > + Generate New Key + </Button> + </div> + </div> + + <div className="flex justify-end space-x-4"> + <Button + variant="secondary" + type="button" + onClick={() => + router.push(`/dashboard/applications/${resolvedParams.id}`) + } + > + Cancel + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "Saving..." : "Save Changes"} + </Button> + </div> + </form> + </div> + </div> + ); +}
A
src/app/dashboard/applications/[id]/page.js
@@ -0,0 +1,109 @@
+"use client"; + +import { useState, useEffect, use } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import Button from "@/components/shared/Button"; + +export default function ApplicationDetailsPage({ params }) { + const router = useRouter(); + const [application, setApplication] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const resolvedParams = use(params); + + useEffect(() => { + fetchApplication(); + }, []); + + async function fetchApplication() { + try { + const response = await fetch( + "http://localhost:8080/twirp/applications.ApplicationsService/GetApplication", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: localStorage.getItem("token"), + application_id: resolvedParams.id, + }), + }, + ); + + const data = await response.json(); + if (data.code || data.msg) { + throw new Error(data.msg || "Failed to fetch application"); + } + + setApplication(data.application); + } catch (error) { + setError(error.message || "Failed to load application"); + console.error("Error:", error); + } finally { + setIsLoading(false); + } + } + + if (isLoading) return <div>Loading...</div>; + if (error) return <div className="text-red-600">{error}</div>; + if (!application) return <div>Application not found</div>; + + return ( + <div className="bg-white shadow rounded-lg p-6"> + <div className="space-y-6"> + <div className="flex justify-between items-start"> + <div> + <h1 className="text-2xl font-bold text-gray-900"> + {application.name} + </h1> + <p className="mt-1 text-sm text-gray-500"> + {application.description} + </p> + </div> + <div className="flex space-x-4"> + <Link href={`/dashboard/applications/${resolvedParams.id}/edit`}> + <Button variant="secondary">Edit Application</Button> + </Link> + </div> + </div> + + <div className="border-t border-gray-200 pt-6"> + <dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2"> + <div> + <dt className="text-sm font-medium text-gray-500"> + Application ID + </dt> + <dd className="mt-1 text-sm text-gray-900">{application.id}</dd> + </div> + <div> + <dt className="text-sm font-medium text-gray-500">Created</dt> + <dd className="mt-1 text-sm text-gray-900"> + {new Date(application.created_at).toLocaleString()} + </dd> + </div> + <div> + <dt className="text-sm font-medium text-gray-500"> + Last Updated + </dt> + <dd className="mt-1 text-sm text-gray-900"> + {new Date(application.updated_at).toLocaleString()} + </dd> + </div> + </dl> + </div> + + <div className="border-t border-gray-200 pt-6"> + <h2 className="text-lg font-medium text-gray-900"> + Usage Instructions + </h2> + <p className="mt-2 text-sm text-gray-500"> + Use this application's API key to send logs to Argus. Refer to our + documentation for detailed integration instructions. + </p> + </div> + </div> + </div> + ); +}
A
src/app/dashboard/applications/new/page.js
@@ -0,0 +1,158 @@
+"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Button from "@/components/shared/Button"; +import Input from "@/components/shared/Input"; + +export default function NewApplicationPage() { + const router = useRouter(); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [error, setError] = useState(""); + const [newApplication, setNewApplication] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setIsSubmitting(true); + setError(""); + + try { + const response = await fetch( + "http://localhost:8080/twirp/applications.ApplicationsService/CreateApplication", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: localStorage.getItem("token"), + name, + description, + }), + }, + ); + + const data = await response.json(); + + // Check for Twirp error response + if (data.code || data.msg) { + throw new Error(data.msg || "Failed to create application"); + } + + if (!data.key) { + throw new Error("No API key received"); + } + + setNewApplication({ + key: data.key, + name: data.application.name, + description: data.application.description, + created_at: data.application.created_at, + }); + } catch (error) { + setError(error.message || "Failed to create application"); + console.error("Error:", error); + } finally { + setIsSubmitting(false); + } + } + + return ( + <> + {newApplication ? ( + <div className="bg-white shadow rounded-lg p-6"> + <div className="space-y-4"> + <h2 className="text-lg font-medium text-gray-900"> + Application Created: {newApplication.name} + </h2> + <div className="text-sm text-gray-500"> + <p className="font-medium">Description:</p> + <p>{newApplication.description}</p> + </div> + <div> + <p className="text-sm text-gray-500 mb-2"> + Please copy your API key now. You won't be able to see it again! + </p> + <div className="bg-gray-50 p-4 rounded-md"> + <code className="text-sm break-all">{newApplication.key}</code> + </div> + </div> + <p className="text-xs text-gray-500"> + Created on: {new Date(newApplication.created_at).toLocaleString()} + </p> + <div className="flex justify-end space-x-4"> + <Button + variant="secondary" + onClick={() => { + navigator.clipboard.writeText(newApplication.key); + }} + > + Copy API Key + </Button> + <Button onClick={() => router.push("/dashboard/applications")}> + Done + </Button> + </div> + </div> + </div> + ) : ( + <div className="bg-white shadow rounded-lg p-6"> + <form onSubmit={handleSubmit} className="space-y-6"> + <div> + <h2 className="text-lg font-medium text-gray-900"> + Create New Application + </h2> + <p className="mt-1 text-sm text-gray-500"> + Create a new application to start sending logs to Argus. + </p> + </div> + + {error && ( + <div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded"> + {error} + </div> + )} + + <div className="space-y-4"> + <Input + label="Application Name" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="e.g., My Production App" + required + /> + + <div> + <label className="block text-sm font-medium text-gray-700"> + Description + </label> + <textarea + value={description} + onChange={(e) => setDescription(e.target.value)} + placeholder="Brief description of your application" + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" + rows={3} + /> + </div> + </div> + + <div className="flex justify-end space-x-4"> + <Button + variant="secondary" + type="button" + onClick={() => router.push("/dashboard/applications")} + > + Cancel + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "Creating..." : "Create Application"} + </Button> + </div> + </form> + </div> + )} + </> + ); +}
A
src/app/dashboard/applications/page.js
@@ -0,0 +1,170 @@
+"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import Button from "@/components/shared/Button"; + +export default function ApplicationsPage() { + const [applications, setApplications] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [deletingApps, setDeletingApps] = useState(new Set()); + + useEffect(() => { + fetchApplications(); + }, []); + + async function fetchApplications() { + try { + const response = await fetch( + "http://localhost:8080/twirp/applications.ApplicationsService/ListApplications", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: localStorage.getItem("token"), + }), + }, + ); + + if (!response.ok) throw new Error("Failed to fetch applications"); + + const data = await response.json(); + setApplications(data.applications || []); + } catch (error) { + setError("Failed to load applications"); + console.error("Error:", error); + } finally { + setIsLoading(false); + } + } + + async function handleDeleteApplication(appId) { + if ( + !confirm( + "Are you sure you want to delete this application? This action cannot be undone and will delete all associated logs.", + ) + ) { + return; + } + + setDeletingApps((prev) => new Set([...prev, appId])); + + try { + const response = await fetch( + `http://localhost:8080/twirp/applications.ApplicationsService/DeleteApplication`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + token: localStorage.getItem("token"), + application_id: appId, + }), + }, + ); + + if (!response.ok) throw new Error("Failed to delete application"); + + await fetchApplications(); // Refresh the list + } catch (error) { + setError("Failed to delete application"); + console.error("Error:", error); + } finally { + setDeletingApps((prev) => { + const newSet = new Set(prev); + newSet.delete(appId); + return newSet; + }); + } + } + + if (isLoading) { + return ( + <div className="flex items-center justify-center h-64"> + <div className="text-gray-500">Loading applications...</div> + </div> + ); + } + + if (error) { + return ( + <div className="rounded-lg bg-white shadow p-6"> + <div className="text-red-600">{error}</div> + </div> + ); + } + + if (!applications.length) { + return ( + <div className="rounded-lg bg-white shadow"> + <div className="px-4 py-12 my-0"> + <div className="text-center"> + <h3 className="mt-2 text-lg font-semibold text-gray-900"> + No applications + </h3> + <p className="mt-1 text-sm text-gray-500"> + Create your first application to start sending logs to Argus. + </p> + <div className="mt-6"> + <Link href="/dashboard/applications/new"> + <Button variant="secondary">Create Application</Button> + </Link> + </div> + </div> + </div> + </div> + ); + } + + return ( + <div className="bg-white shadow rounded-lg divide-y divide-gray-200"> + {applications.map((app) => ( + <ApplicationItem + key={app.id} + application={app} + onDelete={() => handleDeleteApplication(app.id)} + isDeleting={deletingApps.has(app.id)} + /> + ))} + </div> + ); +} + +function ApplicationItem({ application, onDelete, isDeleting }) { + return ( + <div className="px-6 py-4"> + <div className="flex items-center justify-between"> + <div className="space-y-1"> + <h3 className="text-lg font-medium text-gray-900"> + {application.name} + </h3> + <p className="text-sm text-gray-500">{application.description}</p> + <div className="text-xs text-gray-400"> + Created on {new Date(application.created_at).toLocaleDateString()} + </div> + </div> + <div className="flex items-center space-x-4"> + <Link + href={`/dashboard/applications/${application.id}`} + className="text-sm text-indigo-600 hover:text-indigo-900" + > + View Details + </Link> + <button + onClick={onDelete} + disabled={isDeleting} + className={`text-sm text-red-600 hover:text-red-900 ${ + isDeleting ? "opacity-50 cursor-not-allowed" : "" + }`} + > + {isDeleting ? "Deleting..." : "Delete"} + </button> + </div> + </div> + </div> + ); +}
M
src/app/dashboard/layout.js
→
src/app/dashboard/layout.js
@@ -77,9 +77,9 @@ <div className="flex items-center gap-4 h-12">
<Link href="/dashboard"> <p className="text-sm text-gray-600 hover:text-gray-800">Home</p> </Link> - <Link href="/dashboard/api-keys"> + <Link href="/dashboard/applications"> <p className="text-sm text-gray-600 hover:text-gray-800"> - API Keys + Applications </p> </Link> <Link href="/dashboard/monitors">