Add the Secrets and Alerts screens
Two backends that had no way in: secrets could only be set through the API, and alerting could only be configured by hand-editing its file. Both are engine-wide operator settings rather than personal ones, so they get sidebar entries of their own instead of tabs under the per-user Settings — and the missing-secret error now names the place the page actually is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
@@ -31,7 +31,7 @@ class SecretNotFound(KeyError):
|
|||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"No secret named '{self.name}' — add it under Settings › Secrets."
|
return f"No secret named '{self.name}' — add it under Secrets."
|
||||||
|
|
||||||
|
|
||||||
class SecretsStore:
|
class SecretsStore:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
Bell,
|
||||||
Home,
|
Home,
|
||||||
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -23,6 +25,10 @@ const baseItems: Item[] = [
|
|||||||
{ icon: Home, title: "Home", path: "/" },
|
{ icon: Home, title: "Home", path: "/" },
|
||||||
{ icon: Workflow, title: "Flows", path: "/flows" },
|
{ icon: Workflow, title: "Flows", path: "/flows" },
|
||||||
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
|
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
|
||||||
|
// Both are engine-wide operator settings rather than personal ones, so they
|
||||||
|
// sit here and not among the per-user tabs under Settings.
|
||||||
|
{ icon: KeyRound, title: "Secrets", path: "/secrets" },
|
||||||
|
{ icon: Bell, title: "Alerts", path: "/alerts" },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|||||||
+141
-99
@@ -9,34 +9,29 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as SignupRouteImport } from './routes/signup'
|
|
||||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
|
||||||
import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
|
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
|
||||||
import { Route as LayoutRouteImport } from './routes/_layout'
|
|
||||||
import { Route as CanvasRouteImport } from './routes/_canvas'
|
import { Route as CanvasRouteImport } from './routes/_canvas'
|
||||||
|
import { Route as LayoutRouteImport } from './routes/_layout'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
|
||||||
|
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||||
|
import { Route as SignupRouteImport } from './routes/signup'
|
||||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||||
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
|
|
||||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
|
||||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||||
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
|
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
|
||||||
|
import { Route as LayoutSecretsRouteImport } from './routes/_layout/secrets'
|
||||||
|
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||||
|
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
|
||||||
import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index'
|
import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index'
|
||||||
import { Route as LayoutDashboardsNameRouteImport } from './routes/_layout/dashboards/$name'
|
|
||||||
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
||||||
|
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
|
||||||
|
import { Route as LayoutDashboardsNameRouteImport } from './routes/_layout/dashboards/$name'
|
||||||
|
|
||||||
const SignupRoute = SignupRouteImport.update({
|
const CanvasRoute = CanvasRouteImport.update({
|
||||||
id: '/signup',
|
id: '/_canvas',
|
||||||
path: '/signup',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
const LayoutRoute = LayoutRouteImport.update({
|
||||||
id: '/reset-password',
|
id: '/_layout',
|
||||||
path: '/reset-password',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const RecoverPasswordRoute = RecoverPasswordRouteImport.update({
|
|
||||||
id: '/recover-password',
|
|
||||||
path: '/recover-password',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
@@ -44,12 +39,19 @@ const LoginRoute = LoginRouteImport.update({
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutRoute = LayoutRouteImport.update({
|
const RecoverPasswordRoute = RecoverPasswordRouteImport.update({
|
||||||
id: '/_layout',
|
id: '/recover-password',
|
||||||
|
path: '/recover-password',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const CanvasRoute = CanvasRouteImport.update({
|
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
||||||
id: '/_canvas',
|
id: '/reset-password',
|
||||||
|
path: '/reset-password',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const SignupRoute = SignupRouteImport.update({
|
||||||
|
id: '/signup',
|
||||||
|
path: '/signup',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
||||||
@@ -57,41 +59,51 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({
|
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||||
id: '/oauth/authorize',
|
id: '/admin',
|
||||||
path: '/oauth/authorize',
|
path: '/admin',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
|
const LayoutAlertsRoute = LayoutAlertsRouteImport.update({
|
||||||
|
id: '/alerts',
|
||||||
|
path: '/alerts',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
|
const LayoutSecretsRoute = LayoutSecretsRouteImport.update({
|
||||||
|
id: '/secrets',
|
||||||
|
path: '/secrets',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
||||||
id: '/settings',
|
id: '/settings',
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({
|
||||||
id: '/admin',
|
id: '/oauth/authorize',
|
||||||
path: '/admin',
|
path: '/oauth/authorize',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
|
||||||
const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({
|
|
||||||
id: '/dashboards/',
|
|
||||||
path: '/dashboards/',
|
|
||||||
getParentRoute: () => LayoutRoute,
|
|
||||||
} as any)
|
} as any)
|
||||||
const CanvasFlowsIndexRoute = CanvasFlowsIndexRouteImport.update({
|
const CanvasFlowsIndexRoute = CanvasFlowsIndexRouteImport.update({
|
||||||
id: '/flows/',
|
id: '/flows/',
|
||||||
path: '/flows/',
|
path: '/flows/',
|
||||||
getParentRoute: () => CanvasRoute,
|
getParentRoute: () => CanvasRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutDashboardsNameRoute = LayoutDashboardsNameRouteImport.update({
|
|
||||||
id: '/dashboards/$name',
|
|
||||||
path: '/dashboards/$name',
|
|
||||||
getParentRoute: () => LayoutRoute,
|
|
||||||
} as any)
|
|
||||||
const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({
|
const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({
|
||||||
id: '/flows/$flowName',
|
id: '/flows/$flowName',
|
||||||
path: '/flows/$flowName',
|
path: '/flows/$flowName',
|
||||||
getParentRoute: () => CanvasRoute,
|
getParentRoute: () => CanvasRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({
|
||||||
|
id: '/dashboards/',
|
||||||
|
path: '/dashboards/',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
|
const LayoutDashboardsNameRoute = LayoutDashboardsNameRouteImport.update({
|
||||||
|
id: '/dashboards/$name',
|
||||||
|
path: '/dashboards/$name',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof LayoutIndexRoute
|
'/': typeof LayoutIndexRoute
|
||||||
@@ -100,6 +112,8 @@ export interface FileRoutesByFullPath {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
|
'/alerts': typeof LayoutAlertsRoute
|
||||||
|
'/secrets': typeof LayoutSecretsRoute
|
||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||||
@@ -114,6 +128,8 @@ export interface FileRoutesByTo {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
|
'/alerts': typeof LayoutAlertsRoute
|
||||||
|
'/secrets': typeof LayoutSecretsRoute
|
||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||||
@@ -130,6 +146,8 @@ export interface FileRoutesById {
|
|||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/_layout/admin': typeof LayoutAdminRoute
|
'/_layout/admin': typeof LayoutAdminRoute
|
||||||
|
'/_layout/alerts': typeof LayoutAlertsRoute
|
||||||
|
'/_layout/secrets': typeof LayoutSecretsRoute
|
||||||
'/_layout/settings': typeof LayoutSettingsRoute
|
'/_layout/settings': typeof LayoutSettingsRoute
|
||||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||||
'/_layout/': typeof LayoutIndexRoute
|
'/_layout/': typeof LayoutIndexRoute
|
||||||
@@ -147,6 +165,8 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
|
| '/alerts'
|
||||||
|
| '/secrets'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/oauth/authorize'
|
| '/oauth/authorize'
|
||||||
| '/flows/$flowName'
|
| '/flows/$flowName'
|
||||||
@@ -161,6 +181,8 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
|
| '/alerts'
|
||||||
|
| '/secrets'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/oauth/authorize'
|
| '/oauth/authorize'
|
||||||
| '/flows/$flowName'
|
| '/flows/$flowName'
|
||||||
@@ -176,6 +198,8 @@ export interface FileRouteTypes {
|
|||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/_layout/admin'
|
| '/_layout/admin'
|
||||||
|
| '/_layout/alerts'
|
||||||
|
| '/_layout/secrets'
|
||||||
| '/_layout/settings'
|
| '/_layout/settings'
|
||||||
| '/oauth/authorize'
|
| '/oauth/authorize'
|
||||||
| '/_layout/'
|
| '/_layout/'
|
||||||
@@ -197,32 +221,11 @@ export interface RootRouteChildren {
|
|||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/signup': {
|
'/_canvas': {
|
||||||
id: '/signup'
|
id: '/_canvas'
|
||||||
path: '/signup'
|
path: ''
|
||||||
fullPath: '/signup'
|
fullPath: '/'
|
||||||
preLoaderRoute: typeof SignupRouteImport
|
preLoaderRoute: typeof CanvasRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/reset-password': {
|
|
||||||
id: '/reset-password'
|
|
||||||
path: '/reset-password'
|
|
||||||
fullPath: '/reset-password'
|
|
||||||
preLoaderRoute: typeof ResetPasswordRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/recover-password': {
|
|
||||||
id: '/recover-password'
|
|
||||||
path: '/recover-password'
|
|
||||||
fullPath: '/recover-password'
|
|
||||||
preLoaderRoute: typeof RecoverPasswordRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/login': {
|
|
||||||
id: '/login'
|
|
||||||
path: '/login'
|
|
||||||
fullPath: '/login'
|
|
||||||
preLoaderRoute: typeof LoginRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_layout': {
|
'/_layout': {
|
||||||
@@ -232,11 +235,32 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutRouteImport
|
preLoaderRoute: typeof LayoutRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_canvas': {
|
'/login': {
|
||||||
id: '/_canvas'
|
id: '/login'
|
||||||
path: ''
|
path: '/login'
|
||||||
fullPath: '/'
|
fullPath: '/login'
|
||||||
preLoaderRoute: typeof CanvasRouteImport
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/recover-password': {
|
||||||
|
id: '/recover-password'
|
||||||
|
path: '/recover-password'
|
||||||
|
fullPath: '/recover-password'
|
||||||
|
preLoaderRoute: typeof RecoverPasswordRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/reset-password': {
|
||||||
|
id: '/reset-password'
|
||||||
|
path: '/reset-password'
|
||||||
|
fullPath: '/reset-password'
|
||||||
|
preLoaderRoute: typeof ResetPasswordRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/signup': {
|
||||||
|
id: '/signup'
|
||||||
|
path: '/signup'
|
||||||
|
fullPath: '/signup'
|
||||||
|
preLoaderRoute: typeof SignupRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_layout/': {
|
'/_layout/': {
|
||||||
@@ -246,12 +270,26 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutIndexRouteImport
|
preLoaderRoute: typeof LayoutIndexRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
'/oauth/authorize': {
|
'/_layout/admin': {
|
||||||
id: '/oauth/authorize'
|
id: '/_layout/admin'
|
||||||
path: '/oauth/authorize'
|
path: '/admin'
|
||||||
fullPath: '/oauth/authorize'
|
fullPath: '/admin'
|
||||||
preLoaderRoute: typeof OauthAuthorizeRouteImport
|
preLoaderRoute: typeof LayoutAdminRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
|
'/_layout/alerts': {
|
||||||
|
id: '/_layout/alerts'
|
||||||
|
path: '/alerts'
|
||||||
|
fullPath: '/alerts'
|
||||||
|
preLoaderRoute: typeof LayoutAlertsRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
|
'/_layout/secrets': {
|
||||||
|
id: '/_layout/secrets'
|
||||||
|
path: '/secrets'
|
||||||
|
fullPath: '/secrets'
|
||||||
|
preLoaderRoute: typeof LayoutSecretsRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
'/_layout/settings': {
|
'/_layout/settings': {
|
||||||
id: '/_layout/settings'
|
id: '/_layout/settings'
|
||||||
@@ -260,19 +298,12 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutSettingsRouteImport
|
preLoaderRoute: typeof LayoutSettingsRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
'/_layout/admin': {
|
'/oauth/authorize': {
|
||||||
id: '/_layout/admin'
|
id: '/oauth/authorize'
|
||||||
path: '/admin'
|
path: '/oauth/authorize'
|
||||||
fullPath: '/admin'
|
fullPath: '/oauth/authorize'
|
||||||
preLoaderRoute: typeof LayoutAdminRouteImport
|
preLoaderRoute: typeof OauthAuthorizeRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
|
||||||
'/_layout/dashboards/': {
|
|
||||||
id: '/_layout/dashboards/'
|
|
||||||
path: '/dashboards'
|
|
||||||
fullPath: '/dashboards/'
|
|
||||||
preLoaderRoute: typeof LayoutDashboardsIndexRouteImport
|
|
||||||
parentRoute: typeof LayoutRoute
|
|
||||||
}
|
}
|
||||||
'/_canvas/flows/': {
|
'/_canvas/flows/': {
|
||||||
id: '/_canvas/flows/'
|
id: '/_canvas/flows/'
|
||||||
@@ -281,13 +312,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof CanvasFlowsIndexRouteImport
|
preLoaderRoute: typeof CanvasFlowsIndexRouteImport
|
||||||
parentRoute: typeof CanvasRoute
|
parentRoute: typeof CanvasRoute
|
||||||
}
|
}
|
||||||
'/_layout/dashboards/$name': {
|
|
||||||
id: '/_layout/dashboards/$name'
|
|
||||||
path: '/dashboards/$name'
|
|
||||||
fullPath: '/dashboards/$name'
|
|
||||||
preLoaderRoute: typeof LayoutDashboardsNameRouteImport
|
|
||||||
parentRoute: typeof LayoutRoute
|
|
||||||
}
|
|
||||||
'/_canvas/flows/$flowName': {
|
'/_canvas/flows/$flowName': {
|
||||||
id: '/_canvas/flows/$flowName'
|
id: '/_canvas/flows/$flowName'
|
||||||
path: '/flows/$flowName'
|
path: '/flows/$flowName'
|
||||||
@@ -295,6 +319,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof CanvasFlowsFlowNameRouteImport
|
preLoaderRoute: typeof CanvasFlowsFlowNameRouteImport
|
||||||
parentRoute: typeof CanvasRoute
|
parentRoute: typeof CanvasRoute
|
||||||
}
|
}
|
||||||
|
'/_layout/dashboards/': {
|
||||||
|
id: '/_layout/dashboards/'
|
||||||
|
path: '/dashboards'
|
||||||
|
fullPath: '/dashboards/'
|
||||||
|
preLoaderRoute: typeof LayoutDashboardsIndexRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
|
'/_layout/dashboards/$name': {
|
||||||
|
id: '/_layout/dashboards/$name'
|
||||||
|
path: '/dashboards/$name'
|
||||||
|
fullPath: '/dashboards/$name'
|
||||||
|
preLoaderRoute: typeof LayoutDashboardsNameRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +351,8 @@ const CanvasRouteWithChildren =
|
|||||||
|
|
||||||
interface LayoutRouteChildren {
|
interface LayoutRouteChildren {
|
||||||
LayoutAdminRoute: typeof LayoutAdminRoute
|
LayoutAdminRoute: typeof LayoutAdminRoute
|
||||||
|
LayoutAlertsRoute: typeof LayoutAlertsRoute
|
||||||
|
LayoutSecretsRoute: typeof LayoutSecretsRoute
|
||||||
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
||||||
LayoutIndexRoute: typeof LayoutIndexRoute
|
LayoutIndexRoute: typeof LayoutIndexRoute
|
||||||
LayoutDashboardsNameRoute: typeof LayoutDashboardsNameRoute
|
LayoutDashboardsNameRoute: typeof LayoutDashboardsNameRoute
|
||||||
@@ -321,6 +361,8 @@ interface LayoutRouteChildren {
|
|||||||
|
|
||||||
const LayoutRouteChildren: LayoutRouteChildren = {
|
const LayoutRouteChildren: LayoutRouteChildren = {
|
||||||
LayoutAdminRoute: LayoutAdminRoute,
|
LayoutAdminRoute: LayoutAdminRoute,
|
||||||
|
LayoutAlertsRoute: LayoutAlertsRoute,
|
||||||
|
LayoutSecretsRoute: LayoutSecretsRoute,
|
||||||
LayoutSettingsRoute: LayoutSettingsRoute,
|
LayoutSettingsRoute: LayoutSettingsRoute,
|
||||||
LayoutIndexRoute: LayoutIndexRoute,
|
LayoutIndexRoute: LayoutIndexRoute,
|
||||||
LayoutDashboardsNameRoute: LayoutDashboardsNameRoute,
|
LayoutDashboardsNameRoute: LayoutDashboardsNameRoute,
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { Plus, Send, Trash2 } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { type AlertsConfig, AlertsService, type Channel } from "@/client"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
import { handleError } from "@/utils"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_layout/alerts")({
|
||||||
|
component: Alerts,
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
title: "Alerts - Fluksio",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const alertsKey = ["alerts", "config"]
|
||||||
|
|
||||||
|
const SECTION =
|
||||||
|
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
|
||||||
|
|
||||||
|
/** What the engine can alert on. Mirrors `ALERTING_EVENTS` in the backend; a
|
||||||
|
* rule with none of them ticked still covers everything, including any event
|
||||||
|
* added there later. */
|
||||||
|
const EVENTS: [string, string][] = [
|
||||||
|
["node_error", "A node failed"],
|
||||||
|
["node_health", "A connection dropped"],
|
||||||
|
["flow_quarantined", "A flow was quarantined"],
|
||||||
|
["task_crashed", "A background task crashed"],
|
||||||
|
["engine_degraded", "The engine is struggling"],
|
||||||
|
["cascade_dropped", "Work was given up on"],
|
||||||
|
["queue_unavailable", "The queue is unreachable"],
|
||||||
|
]
|
||||||
|
|
||||||
|
/** The settings each kind of channel needs, in the order they read best. */
|
||||||
|
const FIELDS: Record<Channel["kind"], [string, string, string][]> = {
|
||||||
|
ntfy: [
|
||||||
|
["server", "Server", "https://ntfy.sh"],
|
||||||
|
["topic", "Topic", "your-topic"],
|
||||||
|
["token", "Token", "only for a protected topic"],
|
||||||
|
],
|
||||||
|
smtp: [["to", "Send to", "someone@example.com"]],
|
||||||
|
webhook: [["url", "URL", "https://example.com/hook"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
const KINDS: Channel["kind"][] = ["ntfy", "smtp", "webhook"]
|
||||||
|
|
||||||
|
/** A setting may hold a `{"$secret": "name"}` reference rather than a literal,
|
||||||
|
* so text that parses as JSON is stored as JSON and survives a round trip. */
|
||||||
|
function parseSetting(text: string): unknown {
|
||||||
|
if (text.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSetting(value: unknown): string {
|
||||||
|
if (value === undefined || value === null) return ""
|
||||||
|
return typeof value === "string" ? value : JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Alerts() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: alertsKey,
|
||||||
|
queryFn: () => AlertsService.readAlertsConfig(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<h1 className="text-2xl">Alerts</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Where the engine goes when something breaks. A channel is somewhere to
|
||||||
|
send to; a rule says which failures go to which channels. The same
|
||||||
|
fault repeating is held back until its cooldown passes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{data ? <AlertsForm initial={data} /> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertsForm({ initial }: { initial: AlertsConfig }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
|
const [draft, setDraft] = useState<AlertsConfig>(initial)
|
||||||
|
const [saved, setSaved] = useState<AlertsConfig>(initial)
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (config: AlertsConfig) =>
|
||||||
|
AlertsService.saveAlertsConfig({ requestBody: config }),
|
||||||
|
onSuccess: (config) => {
|
||||||
|
showSuccessToast("Alerting updated")
|
||||||
|
setDraft(config)
|
||||||
|
setSaved(config)
|
||||||
|
queryClient.invalidateQueries({ queryKey: alertsKey })
|
||||||
|
},
|
||||||
|
onError: handleError.bind(showErrorToast),
|
||||||
|
})
|
||||||
|
|
||||||
|
const test = useMutation({
|
||||||
|
mutationFn: (channelName: string) =>
|
||||||
|
AlertsService.testChannel({ channelName }),
|
||||||
|
onSuccess: (message) => showSuccessToast(message.message),
|
||||||
|
onError: handleError.bind(showErrorToast),
|
||||||
|
})
|
||||||
|
|
||||||
|
const channels = draft.channels ?? []
|
||||||
|
const rules = draft.rules ?? []
|
||||||
|
const dirty = JSON.stringify(draft) !== JSON.stringify(saved)
|
||||||
|
|
||||||
|
const patchChannel = (index: number, patch: Partial<Channel>) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
channels: channels.map((channel, i) =>
|
||||||
|
i === index ? { ...channel, ...patch } : channel,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card p-4 shadow-e1">
|
||||||
|
<div className="grid gap-0.5">
|
||||||
|
<span className="text-sm font-medium">Alerting</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{draft.enabled
|
||||||
|
? "Failures reach the channels below."
|
||||||
|
: "Switched off — failures only reach the log."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={draft.enabled ?? true}
|
||||||
|
aria-label="Alerting enabled"
|
||||||
|
data-testid="alerts-enabled"
|
||||||
|
onCheckedChange={(enabled) => setDraft({ ...draft, enabled })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className={SECTION}>Channels</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
data-testid="add-channel"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
channels: [
|
||||||
|
...channels,
|
||||||
|
{
|
||||||
|
name: `channel-${channels.length + 1}`,
|
||||||
|
kind: "ntfy",
|
||||||
|
enabled: true,
|
||||||
|
config: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
Add channel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{channels.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No channels yet. Add one to have somewhere to send to.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
channels.map((channel, index) => (
|
||||||
|
<div
|
||||||
|
// Names are what rules point at, but they are edited here, so the
|
||||||
|
// position is the only thing that stays put while typing.
|
||||||
|
key={index}
|
||||||
|
className="grid gap-3 rounded-lg border border-border bg-card p-4 shadow-e1"
|
||||||
|
data-testid="channel-card"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={channel.name}
|
||||||
|
aria-label="Channel name"
|
||||||
|
className="max-w-48"
|
||||||
|
onChange={(event) =>
|
||||||
|
patchChannel(index, { name: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={channel.kind}
|
||||||
|
onValueChange={(kind) =>
|
||||||
|
patchChannel(index, { kind: kind as Channel["kind"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32" aria-label="Channel kind">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{KINDS.map((kind) => (
|
||||||
|
<SelectItem key={kind} value={kind}>
|
||||||
|
{kind}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={channel.enabled ?? true}
|
||||||
|
aria-label={`${channel.name} enabled`}
|
||||||
|
onCheckedChange={(enabled) =>
|
||||||
|
patchChannel(index, { enabled })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={dirty || test.isPending}
|
||||||
|
title={
|
||||||
|
dirty
|
||||||
|
? "Save first — the engine sends through the stored channel"
|
||||||
|
: "Send one alert through this channel"
|
||||||
|
}
|
||||||
|
data-testid="test-channel"
|
||||||
|
onClick={() => test.mutate(channel.name)}
|
||||||
|
>
|
||||||
|
<Send />
|
||||||
|
Test
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Remove ${channel.name}`}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
channels: channels.filter((_, i) => i !== index),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
{FIELDS[channel.kind].map(([key, label, placeholder]) => (
|
||||||
|
<div key={key} className="grid gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={showSetting(channel.config?.[key])}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(event) =>
|
||||||
|
patchChannel(index, {
|
||||||
|
config: {
|
||||||
|
...channel.config,
|
||||||
|
[key]: parseSetting(event.target.value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{channels.length > 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A setting can hold{" "}
|
||||||
|
<code className="font-mono">{`{"$secret": "name"}`}</code> instead
|
||||||
|
of the value itself, and the stored secret is used.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className={SECTION}>Rules</h2>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
data-testid="add-rule"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
rules: [
|
||||||
|
...rules,
|
||||||
|
{ events: [], channels: [], cooldown_s: 900 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
Add rule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No rules yet, so nothing is sent. A rule picks the failures worth
|
||||||
|
hearing about and the channels that carry them.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
rules.map((rule, index) => {
|
||||||
|
const events = rule.events ?? []
|
||||||
|
const targets = rule.channels ?? []
|
||||||
|
const patchRule = (patch: Partial<typeof rule>) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
rules: rules.map((r, i) =>
|
||||||
|
i === index ? { ...r, ...patch } : r,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
const toggle = (list: string[], value: string) =>
|
||||||
|
list.includes(value)
|
||||||
|
? list.filter((item) => item !== value)
|
||||||
|
: [...list, value]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="grid gap-4 rounded-lg border border-border bg-card p-4 shadow-e1"
|
||||||
|
data-testid="rule-card"
|
||||||
|
>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={SECTION}>
|
||||||
|
{events.length === 0
|
||||||
|
? "On any failure"
|
||||||
|
: `On ${events.length} of these`}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Remove rule ${index + 1}`}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
rules: rules.filter((_, i) => i !== index),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{EVENTS.map(([event, label]) => (
|
||||||
|
<Label
|
||||||
|
key={event}
|
||||||
|
className="flex items-center gap-2 text-sm font-normal"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={events.includes(event)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
patchRule({ events: toggle(events, event) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<span className={SECTION}>Send to</span>
|
||||||
|
{channels.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Add a channel first.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<Label
|
||||||
|
key={channel.name}
|
||||||
|
className="flex items-center gap-2 text-sm font-normal"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={targets.includes(channel.name)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
patchRule({
|
||||||
|
channels: toggle(targets, channel.name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{channel.name}
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid max-w-56 gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
Cooldown, in seconds
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={rule.cooldown_s ?? 900}
|
||||||
|
aria-label="Cooldown in seconds"
|
||||||
|
onChange={(event) =>
|
||||||
|
patchRule({ cooldown_s: Number(event.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
disabled={!dirty || save.isPending}
|
||||||
|
data-testid="save-alerts"
|
||||||
|
onClick={() => save.mutate(draft)}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
{dirty ? (
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Unsaved changes.
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { KeyRound, Plus, Trash2 } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { SecretsService } from "@/client"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
import { handleError } from "@/utils"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_layout/secrets")({
|
||||||
|
component: Secrets,
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
title: "Secrets - Fluksio",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The flow editor's credential picker reads this too, so saving refreshes it. */
|
||||||
|
const secretsKey = ["secrets"]
|
||||||
|
|
||||||
|
/** A name that survives being a URL path segment, and reads well in a flow. */
|
||||||
|
const NAME_PATTERN = /^[\w.-]+$/
|
||||||
|
|
||||||
|
function Secrets() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: secretsKey,
|
||||||
|
queryFn: () => SecretsService.readSecrets(),
|
||||||
|
})
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [value, setValue] = useState("")
|
||||||
|
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: secretsKey })
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (secret: { name: string; value: string }) =>
|
||||||
|
SecretsService.saveSecret({
|
||||||
|
name: secret.name,
|
||||||
|
requestBody: { value: secret.value },
|
||||||
|
}),
|
||||||
|
onSuccess: (_result, secret) => {
|
||||||
|
showSuccessToast(`Saved '${secret.name}'`)
|
||||||
|
setName("")
|
||||||
|
setValue("")
|
||||||
|
},
|
||||||
|
onError: handleError.bind(showErrorToast),
|
||||||
|
onSettled: refresh,
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (secret: string) =>
|
||||||
|
SecretsService.deleteSecret({ name: secret }),
|
||||||
|
onSuccess: (_result, secret) => {
|
||||||
|
showSuccessToast(`Deleted '${secret}'`)
|
||||||
|
setPendingDelete(null)
|
||||||
|
},
|
||||||
|
onError: handleError.bind(showErrorToast),
|
||||||
|
onSettled: refresh,
|
||||||
|
})
|
||||||
|
|
||||||
|
const names = data?.data ?? []
|
||||||
|
const trimmed = name.trim()
|
||||||
|
const valid = NAME_PATTERN.test(trimmed) && value.length > 0
|
||||||
|
const replacing = names.includes(trimmed)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<h1 className="text-2xl">Secrets</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Credentials your nodes reach for by name, stored encrypted outside the
|
||||||
|
flow store. A value goes in and never comes back out: replace one you
|
||||||
|
have rotated, delete one you no longer use, but nothing here can read
|
||||||
|
it back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="grid gap-2 sm:grid-cols-[1fr_1fr_auto]"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (valid) save.mutate({ name: trimmed, value })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
placeholder="Name"
|
||||||
|
aria-label="Secret name"
|
||||||
|
data-testid="secret-name"
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={value}
|
||||||
|
placeholder="Value"
|
||||||
|
aria-label="Secret value"
|
||||||
|
data-testid="secret-value"
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!valid || save.isPending}
|
||||||
|
data-testid="save-secret"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
{replacing ? "Replace" : "Add"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{names.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No secrets yet. A node parameter written as{" "}
|
||||||
|
<code className="font-mono">{`{"$secret": "name"}`}</code> picks its
|
||||||
|
value up from here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{names.map((secret) => (
|
||||||
|
<div
|
||||||
|
key={secret}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card p-3 shadow-e1"
|
||||||
|
data-testid="secret-row"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 font-mono text-sm">
|
||||||
|
<KeyRound className="size-4 text-muted-foreground" />
|
||||||
|
{secret}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setName(secret)}
|
||||||
|
title="Put this name in the form to store a new value"
|
||||||
|
>
|
||||||
|
Replace
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Delete ${secret}`}
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setPendingDelete(secret)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={pendingDelete !== null}
|
||||||
|
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete '{pendingDelete}'?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Any node whose parameter points at this name stops working until a
|
||||||
|
value is stored under it again. The value cannot be recovered.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPendingDelete(null)}>
|
||||||
|
Keep it
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={remove.isPending}
|
||||||
|
onClick={() => pendingDelete && remove.mutate(pendingDelete)}
|
||||||
|
data-testid="confirm-delete-secret"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user