feat: add support for multiple banners (#13081)

This commit is contained in:
Kayla Washburn-Love
2024-05-08 15:40:43 -06:00
committed by GitHub
parent a4bd50c985
commit d8e0be6ee6
57 changed files with 1483 additions and 820 deletions

View File

@@ -176,7 +176,7 @@ func New(options Options) Agent {
ignorePorts: options.IgnorePorts,
portCacheDuration: options.PortCacheDuration,
reportMetadataInterval: options.ReportMetadataInterval,
serviceBannerRefreshInterval: options.ServiceBannerRefreshInterval,
notificationBannersRefreshInterval: options.ServiceBannerRefreshInterval,
sshMaxTimeout: options.SSHMaxTimeout,
subsystems: options.Subsystems,
addresses: options.Addresses,
@@ -193,7 +193,7 @@ func New(options Options) Agent {
// that gets closed on disconnection. This is used to wait for graceful disconnection from the
// coordinator during shut down.
close(a.coordDisconnected)
a.serviceBanner.Store(new(codersdk.ServiceBannerConfig))
a.notificationBanners.Store(new([]codersdk.BannerConfig))
a.sessionToken.Store(new(string))
a.init()
return a
@@ -234,8 +234,8 @@ type agent struct {
manifest atomic.Pointer[agentsdk.Manifest] // manifest is atomic because values can change after reconnection.
reportMetadataInterval time.Duration
scriptRunner *agentscripts.Runner
serviceBanner atomic.Pointer[codersdk.ServiceBannerConfig] // serviceBanner is atomic because it is periodically updated.
serviceBannerRefreshInterval time.Duration
notificationBanners atomic.Pointer[[]codersdk.BannerConfig] // notificationBanners is atomic because it is periodically updated.
notificationBannersRefreshInterval time.Duration
sessionToken atomic.Pointer[string]
sshServer *agentssh.Server
sshMaxTimeout time.Duration
@@ -274,7 +274,7 @@ func (a *agent) init() {
sshSrv, err := agentssh.NewServer(a.hardCtx, a.logger.Named("ssh-server"), a.prometheusRegistry, a.filesystem, &agentssh.Config{
MaxTimeout: a.sshMaxTimeout,
MOTDFile: func() string { return a.manifest.Load().MOTDFile },
ServiceBanner: func() *codersdk.ServiceBannerConfig { return a.serviceBanner.Load() },
NotificationBanners: func() *[]codersdk.BannerConfig { return a.notificationBanners.Load() },
UpdateEnv: a.updateCommandEnv,
WorkingDirectory: func() string { return a.manifest.Load().Directory },
})
@@ -709,23 +709,26 @@ func (a *agent) setLifecycle(state codersdk.WorkspaceAgentLifecycle) {
// (and must be done before the session actually starts).
func (a *agent) fetchServiceBannerLoop(ctx context.Context, conn drpc.Conn) error {
aAPI := proto.NewDRPCAgentClient(conn)
ticker := time.NewTicker(a.serviceBannerRefreshInterval)
ticker := time.NewTicker(a.notificationBannersRefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
sbp, err := aAPI.GetServiceBanner(ctx, &proto.GetServiceBannerRequest{})
bannersProto, err := aAPI.GetNotificationBanners(ctx, &proto.GetNotificationBannersRequest{})
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
a.logger.Error(ctx, "failed to update service banner", slog.Error(err))
a.logger.Error(ctx, "failed to update notification banners", slog.Error(err))
return err
}
serviceBanner := agentsdk.ServiceBannerFromProto(sbp)
a.serviceBanner.Store(&serviceBanner)
banners := make([]codersdk.BannerConfig, 0, len(bannersProto.NotificationBanners))
for _, bannerProto := range bannersProto.NotificationBanners {
banners = append(banners, agentsdk.BannerConfigFromProto(bannerProto))
}
a.notificationBanners.Store(&banners)
}
}
}
@@ -757,15 +760,18 @@ func (a *agent) run() (retErr error) {
// redial the coder server and retry.
connMan := newAPIConnRoutineManager(a.gracefulCtx, a.hardCtx, a.logger, conn)
connMan.start("init service banner", gracefulShutdownBehaviorStop,
connMan.start("init notification banners", gracefulShutdownBehaviorStop,
func(ctx context.Context, conn drpc.Conn) error {
aAPI := proto.NewDRPCAgentClient(conn)
sbp, err := aAPI.GetServiceBanner(ctx, &proto.GetServiceBannerRequest{})
bannersProto, err := aAPI.GetNotificationBanners(ctx, &proto.GetNotificationBannersRequest{})
if err != nil {
return xerrors.Errorf("fetch service banner: %w", err)
}
serviceBanner := agentsdk.ServiceBannerFromProto(sbp)
a.serviceBanner.Store(&serviceBanner)
banners := make([]codersdk.BannerConfig, 0, len(bannersProto.NotificationBanners))
for _, bannerProto := range bannersProto.NotificationBanners {
banners = append(banners, agentsdk.BannerConfigFromProto(bannerProto))
}
a.notificationBanners.Store(&banners)
return nil
},
)

View File

@@ -614,12 +614,12 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) {
// Set new banner func and wait for the agent to call it to update the
// banner.
ready := make(chan struct{}, 2)
client.SetServiceBannerFunc(func() (codersdk.ServiceBannerConfig, error) {
client.SetNotificationBannersFunc(func() ([]codersdk.BannerConfig, error) {
select {
case ready <- struct{}{}:
default:
}
return test.banner, nil
return []codersdk.BannerConfig{test.banner}, nil
})
<-ready
<-ready // Wait for two updates to ensure the value has propagated.
@@ -2193,15 +2193,15 @@ func setupAgentSSHClient(ctx context.Context, t *testing.T) *ssh.Client {
func setupSSHSession(
t *testing.T,
manifest agentsdk.Manifest,
serviceBanner codersdk.ServiceBannerConfig,
banner codersdk.BannerConfig,
prepareFS func(fs afero.Fs),
opts ...func(*agenttest.Client, *agent.Options),
) *ssh.Session {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
opts = append(opts, func(c *agenttest.Client, o *agent.Options) {
c.SetServiceBannerFunc(func() (codersdk.ServiceBannerConfig, error) {
return serviceBanner, nil
c.SetNotificationBannersFunc(func() ([]codersdk.BannerConfig, error) {
return []codersdk.BannerConfig{banner}, nil
})
})
//nolint:dogsled

View File

@@ -63,7 +63,7 @@ type Config struct {
// file will be displayed to the user upon login.
MOTDFile func() string
// ServiceBanner returns the configuration for the Coder service banner.
ServiceBanner func() *codersdk.ServiceBannerConfig
NotificationBanners func() *[]codersdk.BannerConfig
// UpdateEnv updates the environment variables for the command to be
// executed. It can be used to add, modify or replace environment variables.
UpdateEnv func(current []string) (updated []string, err error)
@@ -123,8 +123,8 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
if config.MOTDFile == nil {
config.MOTDFile = func() string { return "" }
}
if config.ServiceBanner == nil {
config.ServiceBanner = func() *codersdk.ServiceBannerConfig { return &codersdk.ServiceBannerConfig{} }
if config.NotificationBanners == nil {
config.NotificationBanners = func() *[]codersdk.BannerConfig { return &[]codersdk.BannerConfig{} }
}
if config.WorkingDirectory == nil {
config.WorkingDirectory = func() string {
@@ -441,12 +441,15 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy
session.DisablePTYEmulation()
if isLoginShell(session.RawCommand()) {
serviceBanner := s.config.ServiceBanner()
if serviceBanner != nil {
err := showServiceBanner(session, serviceBanner)
banners := s.config.NotificationBanners()
if banners != nil {
for _, banner := range *banners {
err := showNotificationBanner(session, banner)
if err != nil {
logger.Error(ctx, "agent failed to show service banner", slog.Error(err))
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "service_banner").Add(1)
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "notification_banner").Add(1)
break
}
}
}
}
@@ -891,9 +894,9 @@ func isQuietLogin(fs afero.Fs, rawCommand string) bool {
return err == nil
}
// showServiceBanner will write the service banner if enabled and not blank
// showNotificationBanner will write the service banner if enabled and not blank
// along with a blank line for spacing.
func showServiceBanner(session io.Writer, banner *codersdk.ServiceBannerConfig) error {
func showNotificationBanner(session io.Writer, banner codersdk.BannerConfig) error {
if banner.Enabled && banner.Message != "" {
// The banner supports Markdown so we might want to parse it but Markdown is
// still fairly readable in its raw form.

View File

@@ -138,8 +138,8 @@ func (c *Client) GetStartupLogs() []agentsdk.Log {
return c.logs
}
func (c *Client) SetServiceBannerFunc(f func() (codersdk.ServiceBannerConfig, error)) {
c.fakeAgentAPI.SetServiceBannerFunc(f)
func (c *Client) SetNotificationBannersFunc(f func() ([]codersdk.ServiceBannerConfig, error)) {
c.fakeAgentAPI.SetNotificationBannersFunc(f)
}
func (c *Client) PushDERPMapUpdate(update *tailcfg.DERPMap) error {
@@ -171,31 +171,39 @@ type FakeAgentAPI struct {
lifecycleStates []codersdk.WorkspaceAgentLifecycle
metadata map[string]agentsdk.Metadata
getServiceBannerFunc func() (codersdk.ServiceBannerConfig, error)
getNotificationBannersFunc func() ([]codersdk.BannerConfig, error)
}
func (f *FakeAgentAPI) GetManifest(context.Context, *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
return f.manifest, nil
}
func (f *FakeAgentAPI) SetServiceBannerFunc(fn func() (codersdk.ServiceBannerConfig, error)) {
f.Lock()
defer f.Unlock()
f.getServiceBannerFunc = fn
f.logger.Info(context.Background(), "updated ServiceBannerFunc")
}
func (f *FakeAgentAPI) GetServiceBanner(context.Context, *agentproto.GetServiceBannerRequest) (*agentproto.ServiceBanner, error) {
f.Lock()
defer f.Unlock()
if f.getServiceBannerFunc == nil {
func (*FakeAgentAPI) GetServiceBanner(context.Context, *agentproto.GetServiceBannerRequest) (*agentproto.ServiceBanner, error) {
return &agentproto.ServiceBanner{}, nil
}
sb, err := f.getServiceBannerFunc()
func (f *FakeAgentAPI) SetNotificationBannersFunc(fn func() ([]codersdk.BannerConfig, error)) {
f.Lock()
defer f.Unlock()
f.getNotificationBannersFunc = fn
f.logger.Info(context.Background(), "updated notification banners")
}
func (f *FakeAgentAPI) GetNotificationBanners(context.Context, *agentproto.GetNotificationBannersRequest) (*agentproto.GetNotificationBannersResponse, error) {
f.Lock()
defer f.Unlock()
if f.getNotificationBannersFunc == nil {
return &agentproto.GetNotificationBannersResponse{NotificationBanners: []*agentproto.BannerConfig{}}, nil
}
banners, err := f.getNotificationBannersFunc()
if err != nil {
return nil, err
}
return agentsdk.ProtoFromServiceBanner(sb), nil
bannersProto := make([]*agentproto.BannerConfig, 0, len(banners))
for _, banner := range banners {
bannersProto = append(bannersProto, agentsdk.ProtoFromBannerConfig(banner))
}
return &agentproto.GetNotificationBannersResponse{NotificationBanners: bannersProto}, nil
}
func (f *FakeAgentAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error) {

View File

@@ -1859,6 +1859,154 @@ func (x *BatchCreateLogsResponse) GetLogLimitExceeded() bool {
return false
}
type GetNotificationBannersRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *GetNotificationBannersRequest) Reset() {
*x = GetNotificationBannersRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_proto_agent_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GetNotificationBannersRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetNotificationBannersRequest) ProtoMessage() {}
func (x *GetNotificationBannersRequest) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[22]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetNotificationBannersRequest.ProtoReflect.Descriptor instead.
func (*GetNotificationBannersRequest) Descriptor() ([]byte, []int) {
return file_agent_proto_agent_proto_rawDescGZIP(), []int{22}
}
type GetNotificationBannersResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
NotificationBanners []*BannerConfig `protobuf:"bytes,1,rep,name=notification_banners,json=notificationBanners,proto3" json:"notification_banners,omitempty"`
}
func (x *GetNotificationBannersResponse) Reset() {
*x = GetNotificationBannersResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_proto_agent_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GetNotificationBannersResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetNotificationBannersResponse) ProtoMessage() {}
func (x *GetNotificationBannersResponse) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[23]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetNotificationBannersResponse.ProtoReflect.Descriptor instead.
func (*GetNotificationBannersResponse) Descriptor() ([]byte, []int) {
return file_agent_proto_agent_proto_rawDescGZIP(), []int{23}
}
func (x *GetNotificationBannersResponse) GetNotificationBanners() []*BannerConfig {
if x != nil {
return x.NotificationBanners
}
return nil
}
type BannerConfig struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"`
Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
BackgroundColor string `protobuf:"bytes,3,opt,name=background_color,json=backgroundColor,proto3" json:"background_color,omitempty"`
}
func (x *BannerConfig) Reset() {
*x = BannerConfig{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_proto_agent_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *BannerConfig) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BannerConfig) ProtoMessage() {}
func (x *BannerConfig) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[24]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BannerConfig.ProtoReflect.Descriptor instead.
func (*BannerConfig) Descriptor() ([]byte, []int) {
return file_agent_proto_agent_proto_rawDescGZIP(), []int{24}
}
func (x *BannerConfig) GetEnabled() bool {
if x != nil {
return x.Enabled
}
return false
}
func (x *BannerConfig) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *BannerConfig) GetBackgroundColor() string {
if x != nil {
return x.BackgroundColor
}
return ""
}
type WorkspaceApp_Healthcheck struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -1872,7 +2020,7 @@ type WorkspaceApp_Healthcheck struct {
func (x *WorkspaceApp_Healthcheck) Reset() {
*x = WorkspaceApp_Healthcheck{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_proto_agent_proto_msgTypes[22]
mi := &file_agent_proto_agent_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1885,7 +2033,7 @@ func (x *WorkspaceApp_Healthcheck) String() string {
func (*WorkspaceApp_Healthcheck) ProtoMessage() {}
func (x *WorkspaceApp_Healthcheck) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[22]
mi := &file_agent_proto_agent_proto_msgTypes[25]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1936,7 +2084,7 @@ type WorkspaceAgentMetadata_Result struct {
func (x *WorkspaceAgentMetadata_Result) Reset() {
*x = WorkspaceAgentMetadata_Result{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_proto_agent_proto_msgTypes[23]
mi := &file_agent_proto_agent_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1949,7 +2097,7 @@ func (x *WorkspaceAgentMetadata_Result) String() string {
func (*WorkspaceAgentMetadata_Result) ProtoMessage() {}
func (x *WorkspaceAgentMetadata_Result) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[23]
mi := &file_agent_proto_agent_proto_msgTypes[26]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2008,7 +2156,7 @@ type WorkspaceAgentMetadata_Description struct {
func (x *WorkspaceAgentMetadata_Description) Reset() {
*x = WorkspaceAgentMetadata_Description{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_proto_agent_proto_msgTypes[24]
mi := &file_agent_proto_agent_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2021,7 +2169,7 @@ func (x *WorkspaceAgentMetadata_Description) String() string {
func (*WorkspaceAgentMetadata_Description) ProtoMessage() {}
func (x *WorkspaceAgentMetadata_Description) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[24]
mi := &file_agent_proto_agent_proto_msgTypes[27]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2086,7 +2234,7 @@ type Stats_Metric struct {
func (x *Stats_Metric) Reset() {
*x = Stats_Metric{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_proto_agent_proto_msgTypes[27]
mi := &file_agent_proto_agent_proto_msgTypes[30]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2099,7 +2247,7 @@ func (x *Stats_Metric) String() string {
func (*Stats_Metric) ProtoMessage() {}
func (x *Stats_Metric) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[27]
mi := &file_agent_proto_agent_proto_msgTypes[30]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2155,7 +2303,7 @@ type Stats_Metric_Label struct {
func (x *Stats_Metric_Label) Reset() {
*x = Stats_Metric_Label{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_proto_agent_proto_msgTypes[28]
mi := &file_agent_proto_agent_proto_msgTypes[31]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2168,7 +2316,7 @@ func (x *Stats_Metric_Label) String() string {
func (*Stats_Metric_Label) ProtoMessage() {}
func (x *Stats_Metric_Label) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[28]
mi := &file_agent_proto_agent_proto_msgTypes[31]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2210,7 +2358,7 @@ type BatchUpdateAppHealthRequest_HealthUpdate struct {
func (x *BatchUpdateAppHealthRequest_HealthUpdate) Reset() {
*x = BatchUpdateAppHealthRequest_HealthUpdate{}
if protoimpl.UnsafeEnabled {
mi := &file_agent_proto_agent_proto_msgTypes[29]
mi := &file_agent_proto_agent_proto_msgTypes[32]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -2223,7 +2371,7 @@ func (x *BatchUpdateAppHealthRequest_HealthUpdate) String() string {
func (*BatchUpdateAppHealthRequest_HealthUpdate) ProtoMessage() {}
func (x *BatchUpdateAppHealthRequest_HealthUpdate) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[29]
mi := &file_agent_proto_agent_proto_msgTypes[32]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -2594,64 +2742,87 @@ var file_agent_proto_agent_proto_rawDesc = []byte{
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x6c, 0x6f, 0x67, 0x5f, 0x6c, 0x69,
0x6d, 0x69, 0x74, 0x5f, 0x65, 0x78, 0x63, 0x65, 0x65, 0x64, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01,
0x28, 0x08, 0x52, 0x10, 0x6c, 0x6f, 0x67, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x45, 0x78, 0x63, 0x65,
0x65, 0x64, 0x65, 0x64, 0x2a, 0x63, 0x0a, 0x09, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74,
0x68, 0x12, 0x1a, 0x0a, 0x16, 0x41, 0x50, 0x50, 0x5f, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x5f,
0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a,
0x08, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x49,
0x4e, 0x49, 0x54, 0x49, 0x41, 0x4c, 0x49, 0x5a, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x0b, 0x0a,
0x07, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x03, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e,
0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x04, 0x32, 0xf6, 0x05, 0x0a, 0x05, 0x41, 0x67,
0x65, 0x6e, 0x74, 0x12, 0x4b, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65,
0x73, 0x74, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74,
0x12, 0x5a, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61,
0x6e, 0x6e, 0x65, 0x72, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e,
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53,
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x56, 0x0a, 0x0b,
0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x22, 0x2e, 0x63, 0x6f,
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64,
0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70,
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69,
0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c,
0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x72, 0x0a, 0x15, 0x42, 0x61,
0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c,
0x74, 0x68, 0x73, 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x65, 0x64, 0x65, 0x64, 0x22, 0x1f, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x4e, 0x6f, 0x74, 0x69, 0x66,
0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x71, 0x0a, 0x1e, 0x47, 0x65, 0x74, 0x4e, 0x6f, 0x74, 0x69,
0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x14, 0x6e, 0x6f, 0x74, 0x69, 0x66,
0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x62, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x18,
0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x43, 0x6f, 0x6e,
0x66, 0x69, 0x67, 0x52, 0x13, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f,
0x6e, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x22, 0x6d, 0x0a, 0x0c, 0x42, 0x61, 0x6e, 0x6e,
0x65, 0x72, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62,
0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c,
0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x29, 0x0a, 0x10,
0x62, 0x61, 0x63, 0x6b, 0x67, 0x72, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x63, 0x6f, 0x6c, 0x6f, 0x72,
0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x62, 0x61, 0x63, 0x6b, 0x67, 0x72, 0x6f, 0x75,
0x6e, 0x64, 0x43, 0x6f, 0x6c, 0x6f, 0x72, 0x2a, 0x63, 0x0a, 0x09, 0x41, 0x70, 0x70, 0x48, 0x65,
0x61, 0x6c, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x16, 0x41, 0x50, 0x50, 0x5f, 0x48, 0x45, 0x41, 0x4c,
0x54, 0x48, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00,
0x12, 0x0c, 0x0a, 0x08, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, 0x44, 0x10, 0x01, 0x12, 0x10,
0x0a, 0x0c, 0x49, 0x4e, 0x49, 0x54, 0x49, 0x41, 0x4c, 0x49, 0x5a, 0x49, 0x4e, 0x47, 0x10, 0x02,
0x12, 0x0b, 0x0a, 0x07, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x03, 0x12, 0x0d, 0x0a,
0x09, 0x55, 0x4e, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, 0x04, 0x32, 0xef, 0x06, 0x0a,
0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x4b, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e,
0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65,
0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66,
0x65, 0x73, 0x74, 0x12, 0x5a, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76,
0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x1d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76,
0x32, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12,
0x56, 0x0a, 0x0b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x22,
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e,
0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74,
0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64,
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61,
0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x72, 0x0a,
0x15, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48,
0x65, 0x61, 0x6c, 0x74, 0x68, 0x73, 0x12, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64,
0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76,
0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70,
0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e,
0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12,
0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12, 0x6e,
0x0a, 0x13, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74,
0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67,
0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61,
0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65,
0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62,
0x0a, 0x0f, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67,
0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e,
0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f,
0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65,
0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68,
0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f,
0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x33,
0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x4e, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74,
0x75, 0x70, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74,
0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75,
0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75,
0x70, 0x12, 0x6e, 0x0a, 0x13, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72,
0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55,
0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74,
0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x62, 0x0a, 0x0f, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
0x4c, 0x6f, 0x67, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65,
0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74,
0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63,
0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61,
0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x77, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x4e, 0x6f, 0x74, 0x69,
0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x12,
0x2d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32,
0x2e, 0x47, 0x65, 0x74, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e,
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e,
0x47, 0x65, 0x74, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42,
0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x27,
0x5a, 0x25, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64,
0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e,
0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@@ -2667,7 +2838,7 @@ func file_agent_proto_agent_proto_rawDescGZIP() []byte {
}
var file_agent_proto_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 7)
var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 30)
var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 33)
var file_agent_proto_agent_proto_goTypes = []interface{}{
(AppHealth)(0), // 0: coder.agent.v2.AppHealth
(WorkspaceApp_SharingLevel)(0), // 1: coder.agent.v2.WorkspaceApp.SharingLevel
@@ -2698,73 +2869,79 @@ var file_agent_proto_agent_proto_goTypes = []interface{}{
(*Log)(nil), // 26: coder.agent.v2.Log
(*BatchCreateLogsRequest)(nil), // 27: coder.agent.v2.BatchCreateLogsRequest
(*BatchCreateLogsResponse)(nil), // 28: coder.agent.v2.BatchCreateLogsResponse
(*WorkspaceApp_Healthcheck)(nil), // 29: coder.agent.v2.WorkspaceApp.Healthcheck
(*WorkspaceAgentMetadata_Result)(nil), // 30: coder.agent.v2.WorkspaceAgentMetadata.Result
(*WorkspaceAgentMetadata_Description)(nil), // 31: coder.agent.v2.WorkspaceAgentMetadata.Description
nil, // 32: coder.agent.v2.Manifest.EnvironmentVariablesEntry
nil, // 33: coder.agent.v2.Stats.ConnectionsByProtoEntry
(*Stats_Metric)(nil), // 34: coder.agent.v2.Stats.Metric
(*Stats_Metric_Label)(nil), // 35: coder.agent.v2.Stats.Metric.Label
(*BatchUpdateAppHealthRequest_HealthUpdate)(nil), // 36: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate
(*durationpb.Duration)(nil), // 37: google.protobuf.Duration
(*proto.DERPMap)(nil), // 38: coder.tailnet.v2.DERPMap
(*timestamppb.Timestamp)(nil), // 39: google.protobuf.Timestamp
(*GetNotificationBannersRequest)(nil), // 29: coder.agent.v2.GetNotificationBannersRequest
(*GetNotificationBannersResponse)(nil), // 30: coder.agent.v2.GetNotificationBannersResponse
(*BannerConfig)(nil), // 31: coder.agent.v2.BannerConfig
(*WorkspaceApp_Healthcheck)(nil), // 32: coder.agent.v2.WorkspaceApp.Healthcheck
(*WorkspaceAgentMetadata_Result)(nil), // 33: coder.agent.v2.WorkspaceAgentMetadata.Result
(*WorkspaceAgentMetadata_Description)(nil), // 34: coder.agent.v2.WorkspaceAgentMetadata.Description
nil, // 35: coder.agent.v2.Manifest.EnvironmentVariablesEntry
nil, // 36: coder.agent.v2.Stats.ConnectionsByProtoEntry
(*Stats_Metric)(nil), // 37: coder.agent.v2.Stats.Metric
(*Stats_Metric_Label)(nil), // 38: coder.agent.v2.Stats.Metric.Label
(*BatchUpdateAppHealthRequest_HealthUpdate)(nil), // 39: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate
(*durationpb.Duration)(nil), // 40: google.protobuf.Duration
(*proto.DERPMap)(nil), // 41: coder.tailnet.v2.DERPMap
(*timestamppb.Timestamp)(nil), // 42: google.protobuf.Timestamp
}
var file_agent_proto_agent_proto_depIdxs = []int32{
1, // 0: coder.agent.v2.WorkspaceApp.sharing_level:type_name -> coder.agent.v2.WorkspaceApp.SharingLevel
29, // 1: coder.agent.v2.WorkspaceApp.healthcheck:type_name -> coder.agent.v2.WorkspaceApp.Healthcheck
32, // 1: coder.agent.v2.WorkspaceApp.healthcheck:type_name -> coder.agent.v2.WorkspaceApp.Healthcheck
2, // 2: coder.agent.v2.WorkspaceApp.health:type_name -> coder.agent.v2.WorkspaceApp.Health
37, // 3: coder.agent.v2.WorkspaceAgentScript.timeout:type_name -> google.protobuf.Duration
30, // 4: coder.agent.v2.WorkspaceAgentMetadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result
31, // 5: coder.agent.v2.WorkspaceAgentMetadata.description:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description
32, // 6: coder.agent.v2.Manifest.environment_variables:type_name -> coder.agent.v2.Manifest.EnvironmentVariablesEntry
38, // 7: coder.agent.v2.Manifest.derp_map:type_name -> coder.tailnet.v2.DERPMap
40, // 3: coder.agent.v2.WorkspaceAgentScript.timeout:type_name -> google.protobuf.Duration
33, // 4: coder.agent.v2.WorkspaceAgentMetadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result
34, // 5: coder.agent.v2.WorkspaceAgentMetadata.description:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description
35, // 6: coder.agent.v2.Manifest.environment_variables:type_name -> coder.agent.v2.Manifest.EnvironmentVariablesEntry
41, // 7: coder.agent.v2.Manifest.derp_map:type_name -> coder.tailnet.v2.DERPMap
8, // 8: coder.agent.v2.Manifest.scripts:type_name -> coder.agent.v2.WorkspaceAgentScript
7, // 9: coder.agent.v2.Manifest.apps:type_name -> coder.agent.v2.WorkspaceApp
31, // 10: coder.agent.v2.Manifest.metadata:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description
33, // 11: coder.agent.v2.Stats.connections_by_proto:type_name -> coder.agent.v2.Stats.ConnectionsByProtoEntry
34, // 12: coder.agent.v2.Stats.metrics:type_name -> coder.agent.v2.Stats.Metric
34, // 10: coder.agent.v2.Manifest.metadata:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description
36, // 11: coder.agent.v2.Stats.connections_by_proto:type_name -> coder.agent.v2.Stats.ConnectionsByProtoEntry
37, // 12: coder.agent.v2.Stats.metrics:type_name -> coder.agent.v2.Stats.Metric
14, // 13: coder.agent.v2.UpdateStatsRequest.stats:type_name -> coder.agent.v2.Stats
37, // 14: coder.agent.v2.UpdateStatsResponse.report_interval:type_name -> google.protobuf.Duration
40, // 14: coder.agent.v2.UpdateStatsResponse.report_interval:type_name -> google.protobuf.Duration
4, // 15: coder.agent.v2.Lifecycle.state:type_name -> coder.agent.v2.Lifecycle.State
39, // 16: coder.agent.v2.Lifecycle.changed_at:type_name -> google.protobuf.Timestamp
42, // 16: coder.agent.v2.Lifecycle.changed_at:type_name -> google.protobuf.Timestamp
17, // 17: coder.agent.v2.UpdateLifecycleRequest.lifecycle:type_name -> coder.agent.v2.Lifecycle
36, // 18: coder.agent.v2.BatchUpdateAppHealthRequest.updates:type_name -> coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate
39, // 18: coder.agent.v2.BatchUpdateAppHealthRequest.updates:type_name -> coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate
5, // 19: coder.agent.v2.Startup.subsystems:type_name -> coder.agent.v2.Startup.Subsystem
21, // 20: coder.agent.v2.UpdateStartupRequest.startup:type_name -> coder.agent.v2.Startup
30, // 21: coder.agent.v2.Metadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result
33, // 21: coder.agent.v2.Metadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result
23, // 22: coder.agent.v2.BatchUpdateMetadataRequest.metadata:type_name -> coder.agent.v2.Metadata
39, // 23: coder.agent.v2.Log.created_at:type_name -> google.protobuf.Timestamp
42, // 23: coder.agent.v2.Log.created_at:type_name -> google.protobuf.Timestamp
6, // 24: coder.agent.v2.Log.level:type_name -> coder.agent.v2.Log.Level
26, // 25: coder.agent.v2.BatchCreateLogsRequest.logs:type_name -> coder.agent.v2.Log
37, // 26: coder.agent.v2.WorkspaceApp.Healthcheck.interval:type_name -> google.protobuf.Duration
39, // 27: coder.agent.v2.WorkspaceAgentMetadata.Result.collected_at:type_name -> google.protobuf.Timestamp
37, // 28: coder.agent.v2.WorkspaceAgentMetadata.Description.interval:type_name -> google.protobuf.Duration
37, // 29: coder.agent.v2.WorkspaceAgentMetadata.Description.timeout:type_name -> google.protobuf.Duration
3, // 30: coder.agent.v2.Stats.Metric.type:type_name -> coder.agent.v2.Stats.Metric.Type
35, // 31: coder.agent.v2.Stats.Metric.labels:type_name -> coder.agent.v2.Stats.Metric.Label
0, // 32: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate.health:type_name -> coder.agent.v2.AppHealth
11, // 33: coder.agent.v2.Agent.GetManifest:input_type -> coder.agent.v2.GetManifestRequest
13, // 34: coder.agent.v2.Agent.GetServiceBanner:input_type -> coder.agent.v2.GetServiceBannerRequest
15, // 35: coder.agent.v2.Agent.UpdateStats:input_type -> coder.agent.v2.UpdateStatsRequest
18, // 36: coder.agent.v2.Agent.UpdateLifecycle:input_type -> coder.agent.v2.UpdateLifecycleRequest
19, // 37: coder.agent.v2.Agent.BatchUpdateAppHealths:input_type -> coder.agent.v2.BatchUpdateAppHealthRequest
22, // 38: coder.agent.v2.Agent.UpdateStartup:input_type -> coder.agent.v2.UpdateStartupRequest
24, // 39: coder.agent.v2.Agent.BatchUpdateMetadata:input_type -> coder.agent.v2.BatchUpdateMetadataRequest
27, // 40: coder.agent.v2.Agent.BatchCreateLogs:input_type -> coder.agent.v2.BatchCreateLogsRequest
10, // 41: coder.agent.v2.Agent.GetManifest:output_type -> coder.agent.v2.Manifest
12, // 42: coder.agent.v2.Agent.GetServiceBanner:output_type -> coder.agent.v2.ServiceBanner
16, // 43: coder.agent.v2.Agent.UpdateStats:output_type -> coder.agent.v2.UpdateStatsResponse
17, // 44: coder.agent.v2.Agent.UpdateLifecycle:output_type -> coder.agent.v2.Lifecycle
20, // 45: coder.agent.v2.Agent.BatchUpdateAppHealths:output_type -> coder.agent.v2.BatchUpdateAppHealthResponse
21, // 46: coder.agent.v2.Agent.UpdateStartup:output_type -> coder.agent.v2.Startup
25, // 47: coder.agent.v2.Agent.BatchUpdateMetadata:output_type -> coder.agent.v2.BatchUpdateMetadataResponse
28, // 48: coder.agent.v2.Agent.BatchCreateLogs:output_type -> coder.agent.v2.BatchCreateLogsResponse
41, // [41:49] is the sub-list for method output_type
33, // [33:41] is the sub-list for method input_type
33, // [33:33] is the sub-list for extension type_name
33, // [33:33] is the sub-list for extension extendee
0, // [0:33] is the sub-list for field type_name
31, // 26: coder.agent.v2.GetNotificationBannersResponse.notification_banners:type_name -> coder.agent.v2.BannerConfig
40, // 27: coder.agent.v2.WorkspaceApp.Healthcheck.interval:type_name -> google.protobuf.Duration
42, // 28: coder.agent.v2.WorkspaceAgentMetadata.Result.collected_at:type_name -> google.protobuf.Timestamp
40, // 29: coder.agent.v2.WorkspaceAgentMetadata.Description.interval:type_name -> google.protobuf.Duration
40, // 30: coder.agent.v2.WorkspaceAgentMetadata.Description.timeout:type_name -> google.protobuf.Duration
3, // 31: coder.agent.v2.Stats.Metric.type:type_name -> coder.agent.v2.Stats.Metric.Type
38, // 32: coder.agent.v2.Stats.Metric.labels:type_name -> coder.agent.v2.Stats.Metric.Label
0, // 33: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate.health:type_name -> coder.agent.v2.AppHealth
11, // 34: coder.agent.v2.Agent.GetManifest:input_type -> coder.agent.v2.GetManifestRequest
13, // 35: coder.agent.v2.Agent.GetServiceBanner:input_type -> coder.agent.v2.GetServiceBannerRequest
15, // 36: coder.agent.v2.Agent.UpdateStats:input_type -> coder.agent.v2.UpdateStatsRequest
18, // 37: coder.agent.v2.Agent.UpdateLifecycle:input_type -> coder.agent.v2.UpdateLifecycleRequest
19, // 38: coder.agent.v2.Agent.BatchUpdateAppHealths:input_type -> coder.agent.v2.BatchUpdateAppHealthRequest
22, // 39: coder.agent.v2.Agent.UpdateStartup:input_type -> coder.agent.v2.UpdateStartupRequest
24, // 40: coder.agent.v2.Agent.BatchUpdateMetadata:input_type -> coder.agent.v2.BatchUpdateMetadataRequest
27, // 41: coder.agent.v2.Agent.BatchCreateLogs:input_type -> coder.agent.v2.BatchCreateLogsRequest
29, // 42: coder.agent.v2.Agent.GetNotificationBanners:input_type -> coder.agent.v2.GetNotificationBannersRequest
10, // 43: coder.agent.v2.Agent.GetManifest:output_type -> coder.agent.v2.Manifest
12, // 44: coder.agent.v2.Agent.GetServiceBanner:output_type -> coder.agent.v2.ServiceBanner
16, // 45: coder.agent.v2.Agent.UpdateStats:output_type -> coder.agent.v2.UpdateStatsResponse
17, // 46: coder.agent.v2.Agent.UpdateLifecycle:output_type -> coder.agent.v2.Lifecycle
20, // 47: coder.agent.v2.Agent.BatchUpdateAppHealths:output_type -> coder.agent.v2.BatchUpdateAppHealthResponse
21, // 48: coder.agent.v2.Agent.UpdateStartup:output_type -> coder.agent.v2.Startup
25, // 49: coder.agent.v2.Agent.BatchUpdateMetadata:output_type -> coder.agent.v2.BatchUpdateMetadataResponse
28, // 50: coder.agent.v2.Agent.BatchCreateLogs:output_type -> coder.agent.v2.BatchCreateLogsResponse
30, // 51: coder.agent.v2.Agent.GetNotificationBanners:output_type -> coder.agent.v2.GetNotificationBannersResponse
43, // [43:52] is the sub-list for method output_type
34, // [34:43] is the sub-list for method input_type
34, // [34:34] is the sub-list for extension type_name
34, // [34:34] is the sub-list for extension extendee
0, // [0:34] is the sub-list for field type_name
}
func init() { file_agent_proto_agent_proto_init() }
@@ -3038,7 +3215,7 @@ func file_agent_proto_agent_proto_init() {
}
}
file_agent_proto_agent_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*WorkspaceApp_Healthcheck); i {
switch v := v.(*GetNotificationBannersRequest); i {
case 0:
return &v.state
case 1:
@@ -3050,7 +3227,7 @@ func file_agent_proto_agent_proto_init() {
}
}
file_agent_proto_agent_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*WorkspaceAgentMetadata_Result); i {
switch v := v.(*GetNotificationBannersResponse); i {
case 0:
return &v.state
case 1:
@@ -3062,7 +3239,31 @@ func file_agent_proto_agent_proto_init() {
}
}
file_agent_proto_agent_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*WorkspaceAgentMetadata_Description); i {
switch v := v.(*BannerConfig); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_agent_proto_agent_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*WorkspaceApp_Healthcheck); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_agent_proto_agent_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*WorkspaceAgentMetadata_Result); i {
case 0:
return &v.state
case 1:
@@ -3074,6 +3275,18 @@ func file_agent_proto_agent_proto_init() {
}
}
file_agent_proto_agent_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*WorkspaceAgentMetadata_Description); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_agent_proto_agent_proto_msgTypes[30].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*Stats_Metric); i {
case 0:
return &v.state
@@ -3085,7 +3298,7 @@ func file_agent_proto_agent_proto_init() {
return nil
}
}
file_agent_proto_agent_proto_msgTypes[28].Exporter = func(v interface{}, i int) interface{} {
file_agent_proto_agent_proto_msgTypes[31].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*Stats_Metric_Label); i {
case 0:
return &v.state
@@ -3097,7 +3310,7 @@ func file_agent_proto_agent_proto_init() {
return nil
}
}
file_agent_proto_agent_proto_msgTypes[29].Exporter = func(v interface{}, i int) interface{} {
file_agent_proto_agent_proto_msgTypes[32].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*BatchUpdateAppHealthRequest_HealthUpdate); i {
case 0:
return &v.state
@@ -3116,7 +3329,7 @@ func file_agent_proto_agent_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_agent_proto_agent_proto_rawDesc,
NumEnums: 7,
NumMessages: 30,
NumMessages: 33,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -251,6 +251,18 @@ message BatchCreateLogsResponse {
bool log_limit_exceeded = 1;
}
message GetNotificationBannersRequest {}
message GetNotificationBannersResponse {
repeated BannerConfig notification_banners = 1;
}
message BannerConfig {
bool enabled = 1;
string message = 2;
string background_color = 3;
}
service Agent {
rpc GetManifest(GetManifestRequest) returns (Manifest);
rpc GetServiceBanner(GetServiceBannerRequest) returns (ServiceBanner);
@@ -260,4 +272,5 @@ service Agent {
rpc UpdateStartup(UpdateStartupRequest) returns (Startup);
rpc BatchUpdateMetadata(BatchUpdateMetadataRequest) returns (BatchUpdateMetadataResponse);
rpc BatchCreateLogs(BatchCreateLogsRequest) returns (BatchCreateLogsResponse);
rpc GetNotificationBanners(GetNotificationBannersRequest) returns (GetNotificationBannersResponse);
}

View File

@@ -46,6 +46,7 @@ type DRPCAgentClient interface {
UpdateStartup(ctx context.Context, in *UpdateStartupRequest) (*Startup, error)
BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
GetNotificationBanners(ctx context.Context, in *GetNotificationBannersRequest) (*GetNotificationBannersResponse, error)
}
type drpcAgentClient struct {
@@ -130,6 +131,15 @@ func (c *drpcAgentClient) BatchCreateLogs(ctx context.Context, in *BatchCreateLo
return out, nil
}
func (c *drpcAgentClient) GetNotificationBanners(ctx context.Context, in *GetNotificationBannersRequest) (*GetNotificationBannersResponse, error) {
out := new(GetNotificationBannersResponse)
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/GetNotificationBanners", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
if err != nil {
return nil, err
}
return out, nil
}
type DRPCAgentServer interface {
GetManifest(context.Context, *GetManifestRequest) (*Manifest, error)
GetServiceBanner(context.Context, *GetServiceBannerRequest) (*ServiceBanner, error)
@@ -139,6 +149,7 @@ type DRPCAgentServer interface {
UpdateStartup(context.Context, *UpdateStartupRequest) (*Startup, error)
BatchUpdateMetadata(context.Context, *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
BatchCreateLogs(context.Context, *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
GetNotificationBanners(context.Context, *GetNotificationBannersRequest) (*GetNotificationBannersResponse, error)
}
type DRPCAgentUnimplementedServer struct{}
@@ -175,9 +186,13 @@ func (s *DRPCAgentUnimplementedServer) BatchCreateLogs(context.Context, *BatchCr
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
}
func (s *DRPCAgentUnimplementedServer) GetNotificationBanners(context.Context, *GetNotificationBannersRequest) (*GetNotificationBannersResponse, error) {
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
}
type DRPCAgentDescription struct{}
func (DRPCAgentDescription) NumMethods() int { return 8 }
func (DRPCAgentDescription) NumMethods() int { return 9 }
func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
switch n {
@@ -253,6 +268,15 @@ func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver,
in1.(*BatchCreateLogsRequest),
)
}, DRPCAgentServer.BatchCreateLogs, true
case 8:
return "/coder.agent.v2.Agent/GetNotificationBanners", drpcEncoding_File_agent_proto_agent_proto{},
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
return srv.(DRPCAgentServer).
GetNotificationBanners(
ctx,
in1.(*GetNotificationBannersRequest),
)
}, DRPCAgentServer.GetNotificationBanners, true
default:
return "", nil, nil, nil, false
}
@@ -389,3 +413,19 @@ func (x *drpcAgent_BatchCreateLogsStream) SendAndClose(m *BatchCreateLogsRespons
}
return x.CloseSend()
}
type DRPCAgent_GetNotificationBannersStream interface {
drpc.Stream
SendAndClose(*GetNotificationBannersResponse) error
}
type drpcAgent_GetNotificationBannersStream struct {
drpc.Stream
}
func (x *drpcAgent_GetNotificationBannersStream) SendAndClose(m *GetNotificationBannersResponse) error {
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
return err
}
return x.CloseSend()
}

View File

@@ -35,7 +35,7 @@ import (
type API struct {
opts Options
*ManifestAPI
*ServiceBannerAPI
*NotificationBannerAPI
*StatsAPI
*LifecycleAPI
*AppsAPI
@@ -107,7 +107,7 @@ func New(opts Options) *API {
},
}
api.ServiceBannerAPI = &ServiceBannerAPI{
api.NotificationBannerAPI = &NotificationBannerAPI{
appearanceFetcher: opts.AppearanceFetcher,
}

View File

@@ -0,0 +1,39 @@
package agentapi
import (
"context"
"sync/atomic"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/appearance"
"github.com/coder/coder/v2/codersdk/agentsdk"
)
type NotificationBannerAPI struct {
appearanceFetcher *atomic.Pointer[appearance.Fetcher]
}
// Deprecated: GetServiceBanner has been deprecated in favor of GetNotificationBanners.
func (a *NotificationBannerAPI) GetServiceBanner(ctx context.Context, _ *proto.GetServiceBannerRequest) (*proto.ServiceBanner, error) {
cfg, err := (*a.appearanceFetcher.Load()).Fetch(ctx)
if err != nil {
return nil, xerrors.Errorf("fetch appearance: %w", err)
}
return agentsdk.ProtoFromServiceBanner(cfg.ServiceBanner), nil
}
func (a *NotificationBannerAPI) GetNotificationBanners(ctx context.Context, _ *proto.GetNotificationBannersRequest) (*proto.GetNotificationBannersResponse, error) {
cfg, err := (*a.appearanceFetcher.Load()).Fetch(ctx)
if err != nil {
return nil, xerrors.Errorf("fetch appearance: %w", err)
}
banners := make([]*proto.BannerConfig, 0, len(cfg.NotificationBanners))
for _, banner := range cfg.NotificationBanners {
banners = append(banners, agentsdk.ProtoFromBannerConfig(banner))
}
return &proto.GetNotificationBannersResponse{
NotificationBanners: banners,
}, nil
}

View File

@@ -11,36 +11,30 @@ import (
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/appearance"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
)
func TestGetServiceBanner(t *testing.T) {
func TestGetNotificationBanners(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
cfg := codersdk.ServiceBannerConfig{
cfg := []codersdk.BannerConfig{{
Enabled: true,
Message: "hello world",
BackgroundColor: "#000000",
}
Message: "The beep-bop will be boop-beeped on Saturday at 12AM PST.",
BackgroundColor: "#00FF00",
}}
var ff appearance.Fetcher = fakeFetcher{cfg: codersdk.AppearanceConfig{ServiceBanner: cfg}}
var ff appearance.Fetcher = fakeFetcher{cfg: codersdk.AppearanceConfig{NotificationBanners: cfg}}
ptr := atomic.Pointer[appearance.Fetcher]{}
ptr.Store(&ff)
api := &ServiceBannerAPI{
appearanceFetcher: &ptr,
}
resp, err := api.GetServiceBanner(context.Background(), &agentproto.GetServiceBannerRequest{})
api := &NotificationBannerAPI{appearanceFetcher: &ptr}
resp, err := api.GetNotificationBanners(context.Background(), &agentproto.GetNotificationBannersRequest{})
require.NoError(t, err)
require.Equal(t, &agentproto.ServiceBanner{
Enabled: cfg.Enabled,
Message: cfg.Message,
BackgroundColor: cfg.BackgroundColor,
}, resp)
require.Len(t, resp.NotificationBanners, 1)
require.Equal(t, cfg[0], agentsdk.BannerConfigFromProto(resp.NotificationBanners[0]))
})
t.Run("FetchError", func(t *testing.T) {
@@ -51,11 +45,8 @@ func TestGetServiceBanner(t *testing.T) {
ptr := atomic.Pointer[appearance.Fetcher]{}
ptr.Store(&ff)
api := &ServiceBannerAPI{
appearanceFetcher: &ptr,
}
resp, err := api.GetServiceBanner(context.Background(), &agentproto.GetServiceBannerRequest{})
api := &NotificationBannerAPI{appearanceFetcher: &ptr}
resp, err := api.GetNotificationBanners(context.Background(), &agentproto.GetNotificationBannersRequest{})
require.Error(t, err)
require.ErrorIs(t, err, expectedErr)
require.Nil(t, resp)

View File

@@ -1,24 +0,0 @@
package agentapi
import (
"context"
"sync/atomic"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/appearance"
"github.com/coder/coder/v2/codersdk/agentsdk"
)
type ServiceBannerAPI struct {
appearanceFetcher *atomic.Pointer[appearance.Fetcher]
}
func (a *ServiceBannerAPI) GetServiceBanner(ctx context.Context, _ *proto.GetServiceBannerRequest) (*proto.ServiceBanner, error) {
cfg, err := (*a.appearanceFetcher.Load()).Fetch(ctx)
if err != nil {
return nil, xerrors.Errorf("fetch appearance: %w", err)
}
return agentsdk.ProtoFromServiceBanner(cfg.ServiceBanner), nil
}

54
coderd/apidoc/docs.go generated
View File

@@ -8272,8 +8272,19 @@ const docTemplate = `{
"logo_url": {
"type": "string"
},
"notification_banners": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.BannerConfig"
}
},
"service_banner": {
"$ref": "#/definitions/codersdk.ServiceBannerConfig"
"description": "Deprecated: ServiceBanner has been replaced by NotificationBanners.",
"allOf": [
{
"$ref": "#/definitions/codersdk.BannerConfig"
}
]
},
"support_links": {
"type": "array",
@@ -8530,6 +8541,20 @@ const docTemplate = `{
"AutomaticUpdatesNever"
]
},
"codersdk.BannerConfig": {
"type": "object",
"properties": {
"background_color": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"message": {
"type": "string"
}
}
},
"codersdk.BuildInfoResponse": {
"type": "object",
"properties": {
@@ -11060,20 +11085,6 @@ const docTemplate = `{
}
}
},
"codersdk.ServiceBannerConfig": {
"type": "object",
"properties": {
"background_color": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"message": {
"type": "string"
}
}
},
"codersdk.SessionCountDeploymentStats": {
"type": "object",
"properties": {
@@ -11906,8 +11917,19 @@ const docTemplate = `{
"logo_url": {
"type": "string"
},
"notification_banners": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.BannerConfig"
}
},
"service_banner": {
"$ref": "#/definitions/codersdk.ServiceBannerConfig"
"description": "Deprecated: ServiceBanner has been replaced by NotificationBanners.",
"allOf": [
{
"$ref": "#/definitions/codersdk.BannerConfig"
}
]
}
}
},

View File

@@ -7341,8 +7341,19 @@
"logo_url": {
"type": "string"
},
"notification_banners": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.BannerConfig"
}
},
"service_banner": {
"$ref": "#/definitions/codersdk.ServiceBannerConfig"
"description": "Deprecated: ServiceBanner has been replaced by NotificationBanners.",
"allOf": [
{
"$ref": "#/definitions/codersdk.BannerConfig"
}
]
},
"support_links": {
"type": "array",
@@ -7588,6 +7599,20 @@
"enum": ["always", "never"],
"x-enum-varnames": ["AutomaticUpdatesAlways", "AutomaticUpdatesNever"]
},
"codersdk.BannerConfig": {
"type": "object",
"properties": {
"background_color": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"message": {
"type": "string"
}
}
},
"codersdk.BuildInfoResponse": {
"type": "object",
"properties": {
@@ -9960,20 +9985,6 @@
}
}
},
"codersdk.ServiceBannerConfig": {
"type": "object",
"properties": {
"background_color": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"message": {
"type": "string"
}
}
},
"codersdk.SessionCountDeploymentStats": {
"type": "object",
"properties": {
@@ -10763,8 +10774,19 @@
"logo_url": {
"type": "string"
},
"notification_banners": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.BannerConfig"
}
},
"service_banner": {
"$ref": "#/definitions/codersdk.ServiceBannerConfig"
"description": "Deprecated: ServiceBanner has been replaced by NotificationBanners.",
"allOf": [
{
"$ref": "#/definitions/codersdk.BannerConfig"
}
]
}
}
},

View File

@@ -32,6 +32,7 @@ type AGPLFetcher struct{}
func (AGPLFetcher) Fetch(context.Context) (codersdk.AppearanceConfig, error) {
return codersdk.AppearanceConfig{
NotificationBanners: []codersdk.BannerConfig{},
SupportLinks: DefaultSupportLinks,
}, nil
}

View File

@@ -1220,6 +1220,11 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) {
return q.db.GetLogoURL(ctx)
}
func (q *querier) GetNotificationBanners(ctx context.Context) (string, error) {
// No authz checks
return q.db.GetNotificationBanners(ctx)
}
func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceOAuth2ProviderApp); err != nil {
return database.OAuth2ProviderApp{}, err
@@ -1454,11 +1459,6 @@ func (q *querier) GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Ti
return q.db.GetReplicasUpdatedAfter(ctx, updatedAt)
}
func (q *querier) GetServiceBanner(ctx context.Context) (string, error) {
// No authz checks
return q.db.GetServiceBanner(ctx)
}
func (q *querier) GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]database.TailnetAgent, error) {
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceTailnetCoordinator); err != nil {
return nil, err
@@ -3364,6 +3364,13 @@ func (q *querier) UpsertLogoURL(ctx context.Context, value string) error {
return q.db.UpsertLogoURL(ctx, value)
}
func (q *querier) UpsertNotificationBanners(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceDeploymentValues); err != nil {
return err
}
return q.db.UpsertNotificationBanners(ctx, value)
}
func (q *querier) UpsertOAuthSigningKey(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceSystem); err != nil {
return err
@@ -3382,13 +3389,6 @@ func (q *querier) UpsertProvisionerDaemon(ctx context.Context, arg database.Upse
return q.db.UpsertProvisionerDaemon(ctx, arg)
}
func (q *querier) UpsertServiceBanner(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceDeploymentValues); err != nil {
return err
}
return q.db.UpsertServiceBanner(ctx, value)
}
func (q *querier) UpsertTailnetAgent(ctx context.Context, arg database.UpsertTailnetAgentParams) (database.TailnetAgent, error) {
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTailnetCoordinator); err != nil {
return database.TailnetAgent{}, err

View File

@@ -525,7 +525,7 @@ func (s *MethodTestSuite) TestLicense() {
s.Run("UpsertLogoURL", s.Subtest(func(db database.Store, check *expects) {
check.Args("value").Asserts(rbac.ResourceDeploymentValues, rbac.ActionCreate)
}))
s.Run("UpsertServiceBanner", s.Subtest(func(db database.Store, check *expects) {
s.Run("UpsertNotificationBanners", s.Subtest(func(db database.Store, check *expects) {
check.Args("value").Asserts(rbac.ResourceDeploymentValues, rbac.ActionCreate)
}))
s.Run("GetLicenseByID", s.Subtest(func(db database.Store, check *expects) {
@@ -556,8 +556,8 @@ func (s *MethodTestSuite) TestLicense() {
require.NoError(s.T(), err)
check.Args().Asserts().Returns("value")
}))
s.Run("GetServiceBanner", s.Subtest(func(db database.Store, check *expects) {
err := db.UpsertServiceBanner(context.Background(), "value")
s.Run("GetNotificationBanners", s.Subtest(func(db database.Store, check *expects) {
err := db.UpsertNotificationBanners(context.Background(), "value")
require.NoError(s.T(), err)
check.Args().Asserts().Returns("value")
}))

View File

@@ -185,7 +185,7 @@ type data struct {
deploymentID string
derpMeshKey string
lastUpdateCheck []byte
serviceBanner []byte
notificationBanners []byte
healthSettings []byte
applicationName string
logoURL string
@@ -2488,6 +2488,17 @@ func (q *FakeQuerier) GetLogoURL(_ context.Context) (string, error) {
return q.logoURL, nil
}
func (q *FakeQuerier) GetNotificationBanners(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
if q.notificationBanners == nil {
return "", sql.ErrNoRows
}
return string(q.notificationBanners), nil
}
func (q *FakeQuerier) GetOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
q.mutex.Lock()
defer q.mutex.Unlock()
@@ -3027,17 +3038,6 @@ func (q *FakeQuerier) GetReplicasUpdatedAfter(_ context.Context, updatedAt time.
return replicas, nil
}
func (q *FakeQuerier) GetServiceBanner(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
if q.serviceBanner == nil {
return "", sql.ErrNoRows
}
return string(q.serviceBanner), nil
}
func (*FakeQuerier) GetTailnetAgents(context.Context, uuid.UUID) ([]database.TailnetAgent, error) {
return nil, ErrUnimplemented
}
@@ -8251,6 +8251,14 @@ func (q *FakeQuerier) UpsertLogoURL(_ context.Context, data string) error {
return nil
}
func (q *FakeQuerier) UpsertNotificationBanners(_ context.Context, data string) error {
q.mutex.RLock()
defer q.mutex.RUnlock()
q.notificationBanners = []byte(data)
return nil
}
func (q *FakeQuerier) UpsertOAuthSigningKey(_ context.Context, value string) error {
q.mutex.Lock()
defer q.mutex.Unlock()
@@ -8298,14 +8306,6 @@ func (q *FakeQuerier) UpsertProvisionerDaemon(_ context.Context, arg database.Up
return d, nil
}
func (q *FakeQuerier) UpsertServiceBanner(_ context.Context, data string) error {
q.mutex.RLock()
defer q.mutex.RUnlock()
q.serviceBanner = []byte(data)
return nil
}
func (*FakeQuerier) UpsertTailnetAgent(context.Context, database.UpsertTailnetAgentParams) (database.TailnetAgent, error) {
return database.TailnetAgent{}, ErrUnimplemented
}

View File

@@ -646,6 +646,13 @@ func (m metricsStore) GetLogoURL(ctx context.Context) (string, error) {
return url, err
}
func (m metricsStore) GetNotificationBanners(ctx context.Context) (string, error) {
start := time.Now()
r0, r1 := m.s.GetNotificationBanners(ctx)
m.queryLatencies.WithLabelValues("GetNotificationBanners").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderAppByID(ctx, id)
@@ -849,13 +856,6 @@ func (m metricsStore) GetReplicasUpdatedAfter(ctx context.Context, updatedAt tim
return replicas, err
}
func (m metricsStore) GetServiceBanner(ctx context.Context) (string, error) {
start := time.Now()
banner, err := m.s.GetServiceBanner(ctx)
m.queryLatencies.WithLabelValues("GetServiceBanner").Observe(time.Since(start).Seconds())
return banner, err
}
func (m metricsStore) GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]database.TailnetAgent, error) {
start := time.Now()
r0, r1 := m.s.GetTailnetAgents(ctx, id)
@@ -2186,6 +2186,13 @@ func (m metricsStore) UpsertLogoURL(ctx context.Context, value string) error {
return r0
}
func (m metricsStore) UpsertNotificationBanners(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertNotificationBanners(ctx, value)
m.queryLatencies.WithLabelValues("UpsertNotificationBanners").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) UpsertOAuthSigningKey(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertOAuthSigningKey(ctx, value)
@@ -2200,13 +2207,6 @@ func (m metricsStore) UpsertProvisionerDaemon(ctx context.Context, arg database.
return r0, r1
}
func (m metricsStore) UpsertServiceBanner(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertServiceBanner(ctx, value)
m.queryLatencies.WithLabelValues("UpsertServiceBanner").Observe(time.Since(start).Seconds())
return r0
}
func (m metricsStore) UpsertTailnetAgent(ctx context.Context, arg database.UpsertTailnetAgentParams) (database.TailnetAgent, error) {
start := time.Now()
r0, r1 := m.s.UpsertTailnetAgent(ctx, arg)

View File

@@ -1275,6 +1275,21 @@ func (mr *MockStoreMockRecorder) GetLogoURL(arg0 any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogoURL", reflect.TypeOf((*MockStore)(nil).GetLogoURL), arg0)
}
// GetNotificationBanners mocks base method.
func (m *MockStore) GetNotificationBanners(arg0 context.Context) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetNotificationBanners", arg0)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetNotificationBanners indicates an expected call of GetNotificationBanners.
func (mr *MockStoreMockRecorder) GetNotificationBanners(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationBanners", reflect.TypeOf((*MockStore)(nil).GetNotificationBanners), arg0)
}
// GetOAuth2ProviderAppByID mocks base method.
func (m *MockStore) GetOAuth2ProviderAppByID(arg0 context.Context, arg1 uuid.UUID) (database.OAuth2ProviderApp, error) {
m.ctrl.T.Helper()
@@ -1710,21 +1725,6 @@ func (mr *MockStoreMockRecorder) GetReplicasUpdatedAfter(arg0, arg1 any) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetReplicasUpdatedAfter", reflect.TypeOf((*MockStore)(nil).GetReplicasUpdatedAfter), arg0, arg1)
}
// GetServiceBanner mocks base method.
func (m *MockStore) GetServiceBanner(arg0 context.Context) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetServiceBanner", arg0)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetServiceBanner indicates an expected call of GetServiceBanner.
func (mr *MockStoreMockRecorder) GetServiceBanner(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceBanner", reflect.TypeOf((*MockStore)(nil).GetServiceBanner), arg0)
}
// GetTailnetAgents mocks base method.
func (m *MockStore) GetTailnetAgents(arg0 context.Context, arg1 uuid.UUID) ([]database.TailnetAgent, error) {
m.ctrl.T.Helper()
@@ -4577,6 +4577,20 @@ func (mr *MockStoreMockRecorder) UpsertLogoURL(arg0, arg1 any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertLogoURL", reflect.TypeOf((*MockStore)(nil).UpsertLogoURL), arg0, arg1)
}
// UpsertNotificationBanners mocks base method.
func (m *MockStore) UpsertNotificationBanners(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertNotificationBanners", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertNotificationBanners indicates an expected call of UpsertNotificationBanners.
func (mr *MockStoreMockRecorder) UpsertNotificationBanners(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationBanners", reflect.TypeOf((*MockStore)(nil).UpsertNotificationBanners), arg0, arg1)
}
// UpsertOAuthSigningKey mocks base method.
func (m *MockStore) UpsertOAuthSigningKey(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
@@ -4606,20 +4620,6 @@ func (mr *MockStoreMockRecorder) UpsertProvisionerDaemon(arg0, arg1 any) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertProvisionerDaemon", reflect.TypeOf((*MockStore)(nil).UpsertProvisionerDaemon), arg0, arg1)
}
// UpsertServiceBanner mocks base method.
func (m *MockStore) UpsertServiceBanner(arg0 context.Context, arg1 string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertServiceBanner", arg0, arg1)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertServiceBanner indicates an expected call of UpsertServiceBanner.
func (mr *MockStoreMockRecorder) UpsertServiceBanner(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertServiceBanner", reflect.TypeOf((*MockStore)(nil).UpsertServiceBanner), arg0, arg1)
}
// UpsertTailnetAgent mocks base method.
func (m *MockStore) UpsertTailnetAgent(arg0 context.Context, arg1 database.UpsertTailnetAgentParams) (database.TailnetAgent, error) {
m.ctrl.T.Helper()

View File

@@ -0,0 +1 @@
delete from site_configs where key = 'notification_banners';

View File

@@ -0,0 +1,4 @@
update site_configs SET
key = 'notification_banners',
value = concat('[', value, ']')
where key = 'service_banner';

View File

@@ -135,6 +135,7 @@ type sqlcQuerier interface {
GetLicenseByID(ctx context.Context, id int32) (License, error)
GetLicenses(ctx context.Context) ([]License, error)
GetLogoURL(ctx context.Context) (string, error)
GetNotificationBanners(ctx context.Context) (string, error)
GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error)
GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error)
GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error)
@@ -164,7 +165,6 @@ type sqlcQuerier interface {
GetQuotaConsumedForUser(ctx context.Context, ownerID uuid.UUID) (int64, error)
GetReplicaByID(ctx context.Context, id uuid.UUID) (Replica, error)
GetReplicasUpdatedAfter(ctx context.Context, updatedAt time.Time) ([]Replica, error)
GetServiceBanner(ctx context.Context) (string, error)
GetTailnetAgents(ctx context.Context, id uuid.UUID) ([]TailnetAgent, error)
GetTailnetClientsForAgent(ctx context.Context, agentID uuid.UUID) ([]TailnetClient, error)
GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]TailnetPeer, error)
@@ -421,9 +421,9 @@ type sqlcQuerier interface {
UpsertJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg UpsertJFrogXrayScanByWorkspaceAndAgentIDParams) error
UpsertLastUpdateCheck(ctx context.Context, value string) error
UpsertLogoURL(ctx context.Context, value string) error
UpsertNotificationBanners(ctx context.Context, value string) error
UpsertOAuthSigningKey(ctx context.Context, value string) error
UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error)
UpsertServiceBanner(ctx context.Context, value string) error
UpsertTailnetAgent(ctx context.Context, arg UpsertTailnetAgentParams) (TailnetAgent, error)
UpsertTailnetClient(ctx context.Context, arg UpsertTailnetClientParams) (TailnetClient, error)
UpsertTailnetClientSubscription(ctx context.Context, arg UpsertTailnetClientSubscriptionParams) error

View File

@@ -5615,6 +5615,17 @@ func (q *sqlQuerier) GetLogoURL(ctx context.Context) (string, error) {
return value, err
}
const getNotificationBanners = `-- name: GetNotificationBanners :one
SELECT value FROM site_configs WHERE key = 'notification_banners'
`
func (q *sqlQuerier) GetNotificationBanners(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getNotificationBanners)
var value string
err := row.Scan(&value)
return value, err
}
const getOAuthSigningKey = `-- name: GetOAuthSigningKey :one
SELECT value FROM site_configs WHERE key = 'oauth_signing_key'
`
@@ -5626,17 +5637,6 @@ func (q *sqlQuerier) GetOAuthSigningKey(ctx context.Context) (string, error) {
return value, err
}
const getServiceBanner = `-- name: GetServiceBanner :one
SELECT value FROM site_configs WHERE key = 'service_banner'
`
func (q *sqlQuerier) GetServiceBanner(ctx context.Context) (string, error) {
row := q.db.QueryRowContext(ctx, getServiceBanner)
var value string
err := row.Scan(&value)
return value, err
}
const insertDERPMeshKey = `-- name: InsertDERPMeshKey :exec
INSERT INTO site_configs (key, value) VALUES ('derp_mesh_key', $1)
`
@@ -5728,6 +5728,16 @@ func (q *sqlQuerier) UpsertLogoURL(ctx context.Context, value string) error {
return err
}
const upsertNotificationBanners = `-- name: UpsertNotificationBanners :exec
INSERT INTO site_configs (key, value) VALUES ('notification_banners', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notification_banners'
`
func (q *sqlQuerier) UpsertNotificationBanners(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, upsertNotificationBanners, value)
return err
}
const upsertOAuthSigningKey = `-- name: UpsertOAuthSigningKey :exec
INSERT INTO site_configs (key, value) VALUES ('oauth_signing_key', $1)
ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'oauth_signing_key'
@@ -5738,16 +5748,6 @@ func (q *sqlQuerier) UpsertOAuthSigningKey(ctx context.Context, value string) er
return err
}
const upsertServiceBanner = `-- name: UpsertServiceBanner :exec
INSERT INTO site_configs (key, value) VALUES ('service_banner', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'service_banner'
`
func (q *sqlQuerier) UpsertServiceBanner(ctx context.Context, value string) error {
_, err := q.db.ExecContext(ctx, upsertServiceBanner, value)
return err
}
const cleanTailnetCoordinators = `-- name: CleanTailnetCoordinators :exec
DELETE
FROM tailnet_coordinators

View File

@@ -36,12 +36,12 @@ ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'last_update
-- name: GetLastUpdateCheck :one
SELECT value FROM site_configs WHERE key = 'last_update_check';
-- name: UpsertServiceBanner :exec
INSERT INTO site_configs (key, value) VALUES ('service_banner', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'service_banner';
-- name: UpsertNotificationBanners :exec
INSERT INTO site_configs (key, value) VALUES ('notification_banners', $1)
ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notification_banners';
-- name: GetServiceBanner :one
SELECT value FROM site_configs WHERE key = 'service_banner';
-- name: GetNotificationBanners :one
SELECT value FROM site_configs WHERE key = 'notification_banners';
-- name: UpsertLogoURL :exec
INSERT INTO site_configs (key, value) VALUES ('logo_url', $1)

View File

@@ -277,15 +277,15 @@ func ProtoFromApp(a codersdk.WorkspaceApp) (*proto.WorkspaceApp, error) {
}, nil
}
func ServiceBannerFromProto(sbp *proto.ServiceBanner) codersdk.ServiceBannerConfig {
return codersdk.ServiceBannerConfig{
func ServiceBannerFromProto(sbp *proto.ServiceBanner) codersdk.BannerConfig {
return codersdk.BannerConfig{
Enabled: sbp.GetEnabled(),
Message: sbp.GetMessage(),
BackgroundColor: sbp.GetBackgroundColor(),
}
}
func ProtoFromServiceBanner(sb codersdk.ServiceBannerConfig) *proto.ServiceBanner {
func ProtoFromServiceBanner(sb codersdk.BannerConfig) *proto.ServiceBanner {
return &proto.ServiceBanner{
Enabled: sb.Enabled,
Message: sb.Message,
@@ -293,6 +293,22 @@ func ProtoFromServiceBanner(sb codersdk.ServiceBannerConfig) *proto.ServiceBanne
}
}
func BannerConfigFromProto(sbp *proto.BannerConfig) codersdk.BannerConfig {
return codersdk.BannerConfig{
Enabled: sbp.GetEnabled(),
Message: sbp.GetMessage(),
BackgroundColor: sbp.GetBackgroundColor(),
}
}
func ProtoFromBannerConfig(sb codersdk.BannerConfig) *proto.BannerConfig {
return &proto.BannerConfig{
Enabled: sb.Enabled,
Message: sb.Message,
BackgroundColor: sb.BackgroundColor,
}
}
func ProtoFromSubsystems(ss []codersdk.AgentSubsystem) ([]proto.Startup_Subsystem, error) {
ret := make([]proto.Startup_Subsystem, len(ss))
for i, s := range ss {

View File

@@ -2102,17 +2102,24 @@ func (c *Client) DeploymentStats(ctx context.Context) (DeploymentStats, error) {
type AppearanceConfig struct {
ApplicationName string `json:"application_name"`
LogoURL string `json:"logo_url"`
ServiceBanner ServiceBannerConfig `json:"service_banner"`
// Deprecated: ServiceBanner has been replaced by NotificationBanners.
ServiceBanner BannerConfig `json:"service_banner"`
NotificationBanners []BannerConfig `json:"notification_banners"`
SupportLinks []LinkConfig `json:"support_links,omitempty"`
}
type UpdateAppearanceConfig struct {
ApplicationName string `json:"application_name"`
LogoURL string `json:"logo_url"`
ServiceBanner ServiceBannerConfig `json:"service_banner"`
// Deprecated: ServiceBanner has been replaced by NotificationBanners.
ServiceBanner BannerConfig `json:"service_banner"`
NotificationBanners []BannerConfig `json:"notification_banners"`
}
type ServiceBannerConfig struct {
// Deprecated: ServiceBannerConfig has been renamed to BannerConfig.
type ServiceBannerConfig = BannerConfig
type BannerConfig struct {
Enabled bool `json:"enabled"`
Message string `json:"message,omitempty"`
BackgroundColor string `json:"background_color,omitempty"`

21
docs/api/enterprise.md generated
View File

@@ -21,6 +21,13 @@ curl -X GET http://coder-server:8080/api/v2/appearance \
{
"application_name": "string",
"logo_url": "string",
"notification_banners": [
{
"background_color": "string",
"enabled": true,
"message": "string"
}
],
"service_banner": {
"background_color": "string",
"enabled": true,
@@ -64,6 +71,13 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \
{
"application_name": "string",
"logo_url": "string",
"notification_banners": [
{
"background_color": "string",
"enabled": true,
"message": "string"
}
],
"service_banner": {
"background_color": "string",
"enabled": true,
@@ -86,6 +100,13 @@ curl -X PUT http://coder-server:8080/api/v2/appearance \
{
"application_name": "string",
"logo_url": "string",
"notification_banners": [
{
"background_color": "string",
"enabled": true,
"message": "string"
}
],
"service_banner": {
"background_color": "string",
"enabled": true,

60
docs/api/schemas.md generated
View File

@@ -751,6 +751,13 @@
{
"application_name": "string",
"logo_url": "string",
"notification_banners": [
{
"background_color": "string",
"enabled": true,
"message": "string"
}
],
"service_banner": {
"background_color": "string",
"enabled": true,
@@ -769,10 +776,11 @@
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------------------------------------------------------------ | -------- | ------------ | ----------- |
| ---------------------- | ------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------- |
| `application_name` | string | false | | |
| `logo_url` | string | false | | |
| `service_banner` | [codersdk.ServiceBannerConfig](#codersdkservicebannerconfig) | false | | |
| `notification_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | |
| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by NotificationBanners. |
| `support_links` | array of [codersdk.LinkConfig](#codersdklinkconfig) | false | | |
## codersdk.ArchiveTemplateVersionsRequest
@@ -1172,6 +1180,24 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `always` |
| `never` |
## codersdk.BannerConfig
```json
{
"background_color": "string",
"enabled": true,
"message": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------- | -------- | ------------ | ----------- |
| `background_color` | string | false | | |
| `enabled` | boolean | false | | |
| `message` | string | false | | |
## codersdk.BuildInfoResponse
```json
@@ -4264,24 +4290,6 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `ssh_config_options` | object | false | | |
| » `[any property]` | string | false | | |
## codersdk.ServiceBannerConfig
```json
{
"background_color": "string",
"enabled": true,
"message": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------- | -------- | ------------ | ----------- |
| `background_color` | string | false | | |
| `enabled` | boolean | false | | |
| `message` | string | false | | |
## codersdk.SessionCountDeploymentStats
```json
@@ -5174,6 +5182,13 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
{
"application_name": "string",
"logo_url": "string",
"notification_banners": [
{
"background_color": "string",
"enabled": true,
"message": "string"
}
],
"service_banner": {
"background_color": "string",
"enabled": true,
@@ -5185,10 +5200,11 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------------------------------------------------------------ | -------- | ------------ | ----------- |
| ---------------------- | ------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------- |
| `application_name` | string | false | | |
| `logo_url` | string | false | | |
| `service_banner` | [codersdk.ServiceBannerConfig](#codersdkservicebannerconfig) | false | | |
| `notification_banners` | array of [codersdk.BannerConfig](#codersdkbannerconfig) | false | | |
| `service_banner` | [codersdk.BannerConfig](#codersdkbannerconfig) | false | | Deprecated: ServiceBanner has been replaced by NotificationBanners. |
## codersdk.UpdateCheckResponse

View File

@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"golang.org/x/sync/errgroup"
@@ -53,9 +54,11 @@ func newAppearanceFetcher(store database.Store, links []codersdk.LinkConfig) agp
func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfig, error) {
var eg errgroup.Group
var applicationName string
var logoURL string
var serviceBannerJSON string
var (
applicationName string
logoURL string
notificationBannersJSON string
)
eg.Go(func() (err error) {
applicationName, err = f.database.GetApplicationName(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
@@ -71,9 +74,9 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi
return nil
})
eg.Go(func() (err error) {
serviceBannerJSON, err = f.database.GetServiceBanner(ctx)
notificationBannersJSON, err = f.database.GetNotificationBanners(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get service banner: %w", err)
return xerrors.Errorf("get notification banners: %w", err)
}
return nil
})
@@ -85,19 +88,25 @@ func (f *appearanceFetcher) Fetch(ctx context.Context) (codersdk.AppearanceConfi
cfg := codersdk.AppearanceConfig{
ApplicationName: applicationName,
LogoURL: logoURL,
}
if serviceBannerJSON != "" {
err = json.Unmarshal([]byte(serviceBannerJSON), &cfg.ServiceBanner)
if err != nil {
return codersdk.AppearanceConfig{}, xerrors.Errorf(
"unmarshal json: %w, raw: %s", err, serviceBannerJSON,
)
}
NotificationBanners: []codersdk.BannerConfig{},
SupportLinks: agpl.DefaultSupportLinks,
}
if len(f.supportLinks) == 0 {
cfg.SupportLinks = agpl.DefaultSupportLinks
} else {
if notificationBannersJSON != "" {
err = json.Unmarshal([]byte(notificationBannersJSON), &cfg.NotificationBanners)
if err != nil {
return codersdk.AppearanceConfig{}, xerrors.Errorf(
"unmarshal notification banners json: %w, raw: %s", err, notificationBannersJSON,
)
}
// Redundant, but improves compatibility with slightly mismatched agent versions.
// Maybe we can remove this after a grace period? -Kayla, May 6th 2024
if len(cfg.NotificationBanners) > 0 {
cfg.ServiceBanner = cfg.NotificationBanners[0]
}
}
if len(f.supportLinks) > 0 {
cfg.SupportLinks = f.supportLinks
}
@@ -139,29 +148,32 @@ func (api *API) putAppearance(rw http.ResponseWriter, r *http.Request) {
return
}
if appearance.ServiceBanner.Enabled {
if err := validateHexColor(appearance.ServiceBanner.BackgroundColor); err != nil {
for _, banner := range appearance.NotificationBanners {
if err := validateHexColor(banner.BackgroundColor); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid color format",
Message: fmt.Sprintf("Invalid color format: %q", banner.BackgroundColor),
Detail: err.Error(),
})
return
}
}
serviceBannerJSON, err := json.Marshal(appearance.ServiceBanner)
if appearance.NotificationBanners == nil {
appearance.NotificationBanners = []codersdk.BannerConfig{}
}
notificationBannersJSON, err := json.Marshal(appearance.NotificationBanners)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Unable to marshal service banner",
Message: "Unable to marshal notification banners",
Detail: err.Error(),
})
return
}
err = api.Database.UpsertServiceBanner(ctx, string(serviceBannerJSON))
err = api.Database.UpsertNotificationBanners(ctx, string(notificationBannersJSON))
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Unable to set service banner",
Message: "Unable to set notification banners",
Detail: err.Error(),
})
return

View File

@@ -6,7 +6,6 @@ import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/proto"
@@ -56,7 +55,7 @@ func TestCustomLogoAndCompanyName(t *testing.T) {
require.Equal(t, uac.LogoURL, got.LogoURL)
}
func TestServiceBanners(t *testing.T) {
func TestNotificationBanners(t *testing.T) {
t.Parallel()
t.Run("User", func(t *testing.T) {
@@ -68,10 +67,10 @@ func TestServiceBanners(t *testing.T) {
adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
basicUserClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID)
// Even without a license, the banner should return as disabled.
// Without a license, there should be no banners.
sb, err := basicUserClient.Appearance(ctx)
require.NoError(t, err)
require.False(t, sb.ServiceBanner.Enabled)
require.Empty(t, sb.NotificationBanners)
coderdenttest.AddLicense(t, adminClient, coderdenttest.LicenseOptions{
Features: license.Features{
@@ -82,43 +81,42 @@ func TestServiceBanners(t *testing.T) {
// Default state
sb, err = basicUserClient.Appearance(ctx)
require.NoError(t, err)
require.False(t, sb.ServiceBanner.Enabled)
require.Empty(t, sb.NotificationBanners)
uac := codersdk.UpdateAppearanceConfig{
ServiceBanner: sb.ServiceBanner,
}
// Regular user should be unable to set the banner
uac.ServiceBanner.Enabled = true
uac := codersdk.UpdateAppearanceConfig{
NotificationBanners: []codersdk.BannerConfig{{Enabled: true}},
}
err = basicUserClient.UpdateAppearance(ctx, uac)
require.Error(t, err)
var sdkError *codersdk.Error
require.True(t, errors.As(err, &sdkError))
require.ErrorAs(t, err, &sdkError)
require.Equal(t, http.StatusForbidden, sdkError.StatusCode())
// But an admin can
wantBanner := uac
wantBanner.ServiceBanner.Enabled = true
wantBanner.ServiceBanner.Message = "Hey"
wantBanner.ServiceBanner.BackgroundColor = "#00FF00"
wantBanner := codersdk.UpdateAppearanceConfig{
NotificationBanners: []codersdk.BannerConfig{{
Enabled: true,
Message: "The beep-bop will be boop-beeped on Saturday at 12AM PST.",
BackgroundColor: "#00FF00",
}},
}
err = adminClient.UpdateAppearance(ctx, wantBanner)
require.NoError(t, err)
gotBanner, err := adminClient.Appearance(ctx) //nolint:gocritic // we should assert at least once that the owner can get the banner
require.NoError(t, err)
gotBanner.SupportLinks = nil // clean "support links" before comparison
require.Equal(t, wantBanner.ServiceBanner, gotBanner.ServiceBanner)
require.Equal(t, wantBanner.NotificationBanners, gotBanner.NotificationBanners)
// But even an admin can't give a bad color
wantBanner.ServiceBanner.BackgroundColor = "#bad color"
wantBanner.NotificationBanners[0].BackgroundColor = "#bad color"
err = adminClient.UpdateAppearance(ctx, wantBanner)
require.Error(t, err)
var sdkErr *codersdk.Error
if assert.ErrorAs(t, err, &sdkErr) {
assert.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
assert.Contains(t, sdkErr.Message, "Invalid color format")
assert.Contains(t, sdkErr.Detail, "expected # prefix and 6 characters")
}
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Message, "Invalid color format")
require.Contains(t, sdkErr.Detail, "expected # prefix and 6 characters")
})
t.Run("Agent", func(t *testing.T) {
@@ -141,11 +139,11 @@ func TestServiceBanners(t *testing.T) {
},
})
cfg := codersdk.UpdateAppearanceConfig{
ServiceBanner: codersdk.ServiceBannerConfig{
NotificationBanners: []codersdk.BannerConfig{{
Enabled: true,
Message: "Hey",
Message: "The beep-bop will be boop-beeped on Saturday at 12AM PST.",
BackgroundColor: "#00FF00",
},
}},
}
err := client.UpdateAppearance(ctx, cfg)
require.NoError(t, err)
@@ -157,34 +155,38 @@ func TestServiceBanners(t *testing.T) {
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(r.AgentToken)
banner := requireGetServiceBanner(ctx, t, agentClient)
require.Equal(t, cfg.ServiceBanner, banner)
banners := requireGetNotificationBanners(ctx, t, agentClient)
require.Equal(t, cfg.NotificationBanners, banners)
// Create an AGPL Coderd against the same database
agplClient := coderdtest.New(t, &coderdtest.Options{Database: store, Pubsub: ps})
agplAgentClient := agentsdk.New(agplClient.URL)
agplAgentClient.SetSessionToken(r.AgentToken)
banner = requireGetServiceBanner(ctx, t, agplAgentClient)
require.Equal(t, codersdk.ServiceBannerConfig{}, banner)
banners = requireGetNotificationBanners(ctx, t, agplAgentClient)
require.Equal(t, []codersdk.BannerConfig{}, banners)
// No license means no banner.
err = client.DeleteLicense(ctx, lic.ID)
require.NoError(t, err)
banner = requireGetServiceBanner(ctx, t, agentClient)
require.Equal(t, codersdk.ServiceBannerConfig{}, banner)
banners = requireGetNotificationBanners(ctx, t, agentClient)
require.Equal(t, []codersdk.BannerConfig{}, banners)
})
}
func requireGetServiceBanner(ctx context.Context, t *testing.T, client *agentsdk.Client) codersdk.ServiceBannerConfig {
func requireGetNotificationBanners(ctx context.Context, t *testing.T, client *agentsdk.Client) []codersdk.BannerConfig {
cc, err := client.ConnectRPC(ctx)
require.NoError(t, err)
defer func() {
_ = cc.Close()
}()
aAPI := proto.NewDRPCAgentClient(cc)
sbp, err := aAPI.GetServiceBanner(ctx, &proto.GetServiceBannerRequest{})
bannersProto, err := aAPI.GetNotificationBanners(ctx, &proto.GetNotificationBannersRequest{})
require.NoError(t, err)
return agentsdk.ServiceBannerFromProto(sbp)
banners := make([]codersdk.BannerConfig, 0, len(bannersProto.NotificationBanners))
for _, bannerProto := range bannersProto.NotificationBanners {
banners = append(banners, agentsdk.BannerConfigFromProto(bannerProto))
}
return banners
}
func TestCustomSupportLinks(t *testing.T) {

View File

@@ -1357,6 +1357,7 @@ export const getAppearance = async (): Promise<TypesGen.AppearanceConfig> => {
service_banner: {
enabled: false,
},
notification_banners: [],
};
}
throw ex;

View File

@@ -4,12 +4,12 @@ import type { AppearanceConfig } from "api/typesGenerated";
import type { MetadataState } from "hooks/useEmbeddedMetadata";
import { cachedQuery } from "./util";
const appearanceConfigKey = ["appearance"] as const;
export const appearanceConfigKey = ["appearance"] as const;
export const appearance = (metadata: MetadataState<AppearanceConfig>) => {
return cachedQuery({
metadata,
queryKey: ["appearance"],
queryKey: appearanceConfigKey,
queryFn: () => API.getAppearance(),
});
};

View File

@@ -48,7 +48,8 @@ export interface AppHostResponse {
export interface AppearanceConfig {
readonly application_name: string;
readonly logo_url: string;
readonly service_banner: ServiceBannerConfig;
readonly service_banner: BannerConfig;
readonly notification_banners: readonly BannerConfig[];
readonly support_links?: readonly LinkConfig[];
}
@@ -157,6 +158,13 @@ export interface AvailableExperiments {
readonly safe: readonly Experiment[];
}
// From codersdk/deployment.go
export interface BannerConfig {
readonly enabled: boolean;
readonly message?: string;
readonly background_color?: string;
}
// From codersdk/deployment.go
export interface BuildInfoResponse {
readonly external_url: string;
@@ -1281,7 +1289,8 @@ export interface UpdateActiveTemplateVersion {
export interface UpdateAppearanceConfig {
readonly application_name: string;
readonly logo_url: string;
readonly service_banner: ServiceBannerConfig;
readonly service_banner: BannerConfig;
readonly notification_banners: readonly BannerConfig[];
}
// From codersdk/updatecheck.go

View File

@@ -7,7 +7,7 @@ import { Outlet } from "react-router-dom";
import { Loader } from "components/Loader/Loader";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { LicenseBanner } from "modules/dashboard/LicenseBanner/LicenseBanner";
import { ServiceBanner } from "modules/dashboard/ServiceBanner/ServiceBanner";
import { NotificationBanners } from "modules/dashboard/NotificationBanners/NotificationBanners";
import { dashboardContentBottomPadding } from "theme/constants";
import { docs } from "utils/docs";
import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner";
@@ -21,8 +21,8 @@ export const DashboardLayout: FC = () => {
return (
<>
<ServiceBanner />
{canViewDeployment && <LicenseBanner />}
<NotificationBanners />
<div
css={{

View File

@@ -1,10 +1,4 @@
import {
createContext,
type FC,
type PropsWithChildren,
useCallback,
useState,
} from "react";
import { createContext, type FC, type PropsWithChildren } from "react";
import { useQuery } from "react-query";
import { appearance } from "api/queries/appearance";
import { entitlements } from "api/queries/entitlements";
@@ -14,21 +8,13 @@ import type {
Entitlements,
Experiments,
} from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { Loader } from "components/Loader/Loader";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { hslToHex, isHexColor, isHslColor } from "utils/colors";
interface Appearance {
config: AppearanceConfig;
isPreview: boolean;
setPreview: (config: AppearanceConfig) => void;
}
export interface DashboardValue {
entitlements: Entitlements;
experiments: Experiments;
appearance: Appearance;
appearance: AppearanceConfig;
}
export const DashboardContext = createContext<DashboardValue | undefined>(
@@ -44,34 +30,6 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
const isLoading =
!entitlementsQuery.data || !appearanceQuery.data || !experimentsQuery.data;
const [configPreview, setConfigPreview] = useState<AppearanceConfig>();
// Centralizing the logic for catching malformed configs in one spot, just to
// be on the safe side; don't want to expose raw setConfigPreview outside
// the provider
const setPreview = useCallback((newConfig: AppearanceConfig) => {
// Have runtime safety nets in place, just because so much of the codebase
// relies on HSL for formatting, but server expects hex values. Can't catch
// color format mismatches at the type level
const incomingBg = newConfig.service_banner.background_color;
let configForDispatch = newConfig;
if (typeof incomingBg === "string" && isHslColor(incomingBg)) {
configForDispatch = {
...newConfig,
service_banner: {
...newConfig.service_banner,
background_color: hslToHex(incomingBg),
},
};
} else if (typeof incomingBg === "string" && !isHexColor(incomingBg)) {
displayError(`The value ${incomingBg} is not a valid hex string`);
return;
}
setConfigPreview(configForDispatch);
}, []);
if (isLoading) {
return <Loader fullscreen />;
}
@@ -81,11 +39,7 @@ export const DashboardProvider: FC<PropsWithChildren> = ({ children }) => {
value={{
entitlements: entitlementsQuery.data,
experiments: experimentsQuery.data,
appearance: {
config: configPreview ?? appearanceQuery.data,
setPreview: setPreview,
isPreview: configPreview !== undefined,
},
appearance: appearanceQuery.data,
}}
>
{children}

View File

@@ -25,9 +25,9 @@ export const Navbar: FC = () => {
return (
<NavbarView
user={me}
logo_url={appearance.config.logo_url}
logo_url={appearance.logo_url}
buildInfo={buildInfoQuery.data}
supportLinks={appearance.config.support_links}
supportLinks={appearance.support_links}
onSignOut={signOut}
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}

View File

@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react";
import { NotificationBannerView } from "./NotificationBannerView";
const meta: Meta<typeof NotificationBannerView> = {
title: "modules/dashboard/NotificationBannerView",
component: NotificationBannerView,
};
export default meta;
type Story = StoryObj<typeof NotificationBannerView>;
export const Production: Story = {
args: {
message: "Unfortunately, there's a radio connected to my brain.",
backgroundColor: "#ffaff3",
},
};
export const Preview: Story = {
args: {
message: "バアン バン バン バン バアン ブレイバアン!",
backgroundColor: "#4cd473",
},
};

View File

@@ -1,28 +1,30 @@
import { css, type Interpolation, type Theme } from "@emotion/react";
import type { FC } from "react";
import { InlineMarkdown } from "components/Markdown/Markdown";
import { Pill } from "components/Pill/Pill";
import { readableForegroundColor } from "utils/colors";
export interface ServiceBannerViewProps {
message: string;
backgroundColor: string;
isPreview: boolean;
export interface NotificationBannerViewProps {
message?: string;
backgroundColor?: string;
}
export const ServiceBannerView: FC<ServiceBannerViewProps> = ({
export const NotificationBannerView: FC<NotificationBannerViewProps> = ({
message,
backgroundColor,
isPreview,
}) => {
if (!message || !backgroundColor) {
return null;
}
return (
<div css={[styles.banner, { backgroundColor }]} className="service-banner">
{isPreview && <Pill type="info">Preview</Pill>}
<div
css={[
styles.wrapper,
{ color: readableForegroundColor(backgroundColor) },
]}
css={styles.banner}
style={{ backgroundColor }}
className="service-banner"
>
<div
css={styles.wrapper}
style={{ color: readableForegroundColor(backgroundColor) }}
>
<InlineMarkdown>{message}</InlineMarkdown>
</div>

View File

@@ -0,0 +1,28 @@
import type { FC } from "react";
import { useDashboard } from "modules/dashboard/useDashboard";
import { NotificationBannerView } from "./NotificationBannerView";
export const NotificationBanners: FC = () => {
const { appearance, entitlements } = useDashboard();
const notificationBanners = appearance.notification_banners;
const isEntitled =
entitlements.features.appearance.entitlement !== "not_entitled";
if (!isEntitled) {
return null;
}
return (
<>
{notificationBanners
.filter((banner) => banner.enabled)
.map((banner) => (
<NotificationBannerView
key={banner.message}
message={banner.message}
backgroundColor={banner.background_color}
/>
))}
</>
);
};

View File

@@ -1,21 +0,0 @@
import type { FC } from "react";
import { useDashboard } from "modules/dashboard/useDashboard";
import { ServiceBannerView } from "./ServiceBannerView";
export const ServiceBanner: FC = () => {
const { appearance } = useDashboard();
const { message, background_color, enabled } =
appearance.config.service_banner;
if (!enabled || message === undefined || background_color === undefined) {
return null;
}
return (
<ServiceBannerView
message={message}
backgroundColor={background_color}
isPreview={appearance.isPreview}
/>
);
};

View File

@@ -1,25 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ServiceBannerView } from "./ServiceBannerView";
const meta: Meta<typeof ServiceBannerView> = {
title: "modules/dashboard/ServiceBannerView",
component: ServiceBannerView,
};
export default meta;
type Story = StoryObj<typeof ServiceBannerView>;
export const Production: Story = {
args: {
message: "weeeee",
backgroundColor: "#FFFFFF",
},
};
export const Preview: Story = {
args: {
message: "weeeee",
backgroundColor: "#000000",
isPreview: true,
},
};

View File

@@ -18,12 +18,6 @@ import {
} from "testHelpers/entities";
import { WorkspaceStatusBadge } from "./WorkspaceStatusBadge";
const MockedAppearance = {
config: MockAppearanceConfig,
isPreview: false,
setPreview: () => {},
};
const meta: Meta<typeof WorkspaceStatusBadge> = {
title: "modules/workspaces/WorkspaceStatusBadge",
component: WorkspaceStatusBadge,
@@ -41,7 +35,7 @@ const meta: Meta<typeof WorkspaceStatusBadge> = {
value={{
entitlements: MockEntitlementsWithScheduling,
experiments: MockExperiments,
appearance: MockedAppearance,
appearance: MockAppearanceConfig,
}}
>
<Story />

View File

@@ -2,7 +2,7 @@ import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQueryClient } from "react-query";
import { getErrorMessage } from "api/errors";
import { updateAppearance } from "api/queries/appearance";
import { appearanceConfigKey, updateAppearance } from "api/queries/appearance";
import type { UpdateAppearanceConfig } from "api/typesGenerated";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { useDashboard } from "modules/dashboard/useDashboard";
@@ -20,16 +20,12 @@ const AppearanceSettingsPage: FC = () => {
const onSaveAppearance = async (
newConfig: Partial<UpdateAppearanceConfig>,
preview: boolean,
) => {
const newAppearance = { ...appearance.config, ...newConfig };
if (preview) {
appearance.setPreview(newAppearance);
return;
}
const newAppearance = { ...appearance, ...newConfig };
try {
await updateAppearanceMutation.mutateAsync(newAppearance);
await queryClient.invalidateQueries(appearanceConfigKey);
displaySuccess("Successfully updated appearance settings!");
} catch (error) {
displayError(
@@ -45,7 +41,7 @@ const AppearanceSettingsPage: FC = () => {
</Helmet>
<AppearanceSettingsPageView
appearance={appearance.config}
appearance={appearance}
onSaveAppearance={onSaveAppearance}
isEntitled={
entitlements.features.appearance.entitlement !== "not_entitled"

View File

@@ -9,10 +9,17 @@ const meta: Meta<typeof AppearanceSettingsPageView> = {
application_name: "Foobar",
logo_url: "https://github.com/coder.png",
service_banner: {
enabled: true,
message: "hello world",
background_color: "white",
enabled: false,
message: "",
background_color: "#00ff00",
},
notification_banners: [
{
enabled: true,
message: "The beep-bop will be boop-beeped on Saturday at 12AM PST.",
background_color: "#ffaff3",
},
],
},
isEntitled: false,
},

View File

@@ -1,13 +1,8 @@
import { useTheme } from "@emotion/react";
import Button from "@mui/material/Button";
import FormControlLabel from "@mui/material/FormControlLabel";
import InputAdornment from "@mui/material/InputAdornment";
import Link from "@mui/material/Link";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import { useFormik } from "formik";
import { type FC, useState } from "react";
import { BlockPicker } from "react-color";
import type { FC } from "react";
import type { UpdateAppearanceConfig } from "api/typesGenerated";
import {
Badges,
@@ -15,35 +10,29 @@ import {
EnterpriseBadge,
EntitledBadge,
} from "components/Badges/Badges";
import { Stack } from "components/Stack/Stack";
import colors from "theme/tailwindColors";
import { getFormHelpers } from "utils/formUtils";
import { Fieldset } from "../Fieldset";
import { Header } from "../Header";
import { NotificationBannerSettings } from "./NotificationBannerSettings";
export type AppearanceSettingsPageViewProps = {
appearance: UpdateAppearanceConfig;
isEntitled: boolean;
onSaveAppearance: (
newConfig: Partial<UpdateAppearanceConfig>,
preview: boolean,
) => void;
) => Promise<void>;
};
const fallbackBgColor = colors.neutral[500];
export const AppearanceSettingsPageView: FC<
AppearanceSettingsPageViewProps
> = ({ appearance, isEntitled, onSaveAppearance }) => {
const theme = useTheme();
const applicationNameForm = useFormik<{
application_name: string;
}>({
initialValues: {
application_name: appearance.application_name,
},
onSubmit: (values) => onSaveAppearance(values, false),
onSubmit: (values) => onSaveAppearance(values),
});
const applicationNameFieldHelpers = getFormHelpers(applicationNameForm);
@@ -53,33 +42,10 @@ export const AppearanceSettingsPageView: FC<
initialValues: {
logo_url: appearance.logo_url,
},
onSubmit: (values) => onSaveAppearance(values, false),
onSubmit: (values) => onSaveAppearance(values),
});
const logoFieldHelpers = getFormHelpers(logoForm);
const serviceBannerForm = useFormik<UpdateAppearanceConfig["service_banner"]>(
{
initialValues: {
message: appearance.service_banner.message,
enabled: appearance.service_banner.enabled,
background_color:
appearance.service_banner.background_color ?? fallbackBgColor,
},
onSubmit: (values) =>
onSaveAppearance(
{
service_banner: values,
},
false,
),
},
);
const serviceBannerFieldHelpers = getFormHelpers(serviceBannerForm);
const [backgroundColor, setBackgroundColor] = useState(
serviceBannerForm.values.background_color,
);
return (
<>
<Header
@@ -159,123 +125,13 @@ export const AppearanceSettingsPageView: FC<
/>
</Fieldset>
<Fieldset
title="Service Banner"
subtitle="Configure a banner that displays a message to all users."
onSubmit={serviceBannerForm.handleSubmit}
button={
!isEntitled && (
<Button
onClick={() => {
onSaveAppearance(
{
service_banner: {
message:
"👋 **This** is a service banner. The banner's color and text are editable.",
background_color: "#004852",
enabled: true,
},
},
true,
);
}}
>
Show Preview
</Button>
)
<NotificationBannerSettings
isEntitled={isEntitled}
notificationBanners={appearance.notification_banners || []}
onSubmit={(notificationBanners) =>
onSaveAppearance({ notification_banners: notificationBanners })
}
validation={
!isEntitled && (
<p>
Your license does not include Service Banners.{" "}
<Link href="mailto:sales@coder.com">Contact sales</Link> to learn
more.
</p>
)
}
>
{isEntitled && (
<Stack>
<FormControlLabel
control={
<Switch
checked={serviceBannerForm.values.enabled}
onChange={async () => {
const newState = !serviceBannerForm.values.enabled;
const newBanner = {
...serviceBannerForm.values,
enabled: newState,
};
onSaveAppearance(
{
service_banner: newBanner,
},
false,
);
await serviceBannerForm.setFieldValue("enabled", newState);
}}
data-testid="switch-service-banner"
/>
}
label="Enabled"
/>
<Stack spacing={0}>
<TextField
{...serviceBannerFieldHelpers("message", {
helperText:
"Markdown bold, italics, and links are supported.",
})}
fullWidth
label="Message"
multiline
inputProps={{
"aria-label": "Message",
}}
/>
</Stack>
<Stack spacing={0}>
<h3>{"Background Color"}</h3>
<BlockPicker
color={backgroundColor}
onChange={async (color) => {
setBackgroundColor(color.hex);
await serviceBannerForm.setFieldValue(
"background_color",
color.hex,
);
onSaveAppearance(
{
service_banner: {
...serviceBannerForm.values,
background_color: color.hex,
},
},
true,
);
}}
triangle="hide"
colors={["#004852", "#D65D0F", "#4CD473", "#D94A5D", "#5A00CF"]}
styles={{
default: {
input: {
color: "white",
backgroundColor: theme.palette.background.default,
},
body: {
backgroundColor: "black",
color: "white",
},
card: {
backgroundColor: "black",
},
},
}}
/>
</Stack>
</Stack>
)}
</Fieldset>
</>
);
};

View File

@@ -0,0 +1,24 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { NotificationBannerDialog } from "./NotificationBannerDialog";
const meta: Meta<typeof NotificationBannerDialog> = {
title: "pages/DeploySettingsPage/NotificationBannerDialog",
component: NotificationBannerDialog,
args: {
banner: {
enabled: true,
message: "The beep-bop will be boop-beeped on Saturday at 12AM PST.",
background_color: "#ffaff3",
},
onCancel: action("onCancel"),
onUpdate: () => Promise.resolve(void action("onUpdate")),
},
};
export default meta;
type Story = StoryObj<typeof NotificationBannerDialog>;
const Example: Story = {};
export { Example as NotificationBannerDialog };

View File

@@ -0,0 +1,138 @@
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import DialogActions from "@mui/material/DialogActions";
import TextField from "@mui/material/TextField";
import { useFormik } from "formik";
import type { FC } from "react";
import { BlockPicker } from "react-color";
import type { BannerConfig } from "api/typesGenerated";
import { Dialog, DialogActionButtons } from "components/Dialogs/Dialog";
import { Stack } from "components/Stack/Stack";
import { NotificationBannerView } from "modules/dashboard/NotificationBanners/NotificationBannerView";
import { getFormHelpers } from "utils/formUtils";
interface NotificationBannerDialogProps {
banner: BannerConfig;
onCancel: () => void;
onUpdate: (banner: Partial<BannerConfig>) => Promise<void>;
}
export const NotificationBannerDialog: FC<NotificationBannerDialogProps> = ({
banner,
onCancel,
onUpdate,
}) => {
const theme = useTheme();
const bannerForm = useFormik<{
message: string;
background_color: string;
}>({
initialValues: {
message: banner.message ?? "",
background_color: banner.background_color ?? "#004852",
},
onSubmit: (banner) => onUpdate(banner),
});
const bannerFieldHelpers = getFormHelpers(bannerForm);
return (
<Dialog css={styles.dialogWrapper} open onClose={onCancel}>
{/* Banner preview */}
<div css={{ position: "fixed", top: 0, left: 0, right: 0 }}>
<NotificationBannerView
message={bannerForm.values.message}
backgroundColor={bannerForm.values.background_color}
/>
</div>
<div css={styles.dialogContent}>
<h3 css={styles.dialogTitle}>Notification banner</h3>
<Stack>
<div>
<h4 css={styles.settingName}>Message</h4>
<TextField
{...bannerFieldHelpers("message", {
helperText: "Markdown bold, italics, and links are supported.",
})}
fullWidth
inputProps={{
"aria-label": "Message",
placeholder: "Enter a message for the banner",
}}
/>
</div>
<div>
<h4 css={styles.settingName}>Background color</h4>
<BlockPicker
color={bannerForm.values.background_color}
onChange={async (color) => {
await bannerForm.setFieldValue("background_color", color.hex);
}}
triangle="hide"
colors={["#004852", "#D65D0F", "#4CD473", "#D94A5D", "#5A00CF"]}
styles={{
default: {
input: {
color: "white",
backgroundColor: theme.palette.background.default,
},
body: {
backgroundColor: "black",
color: "white",
},
card: {
backgroundColor: "black",
},
},
}}
/>
</div>
</Stack>
</div>
<DialogActions>
<DialogActionButtons
cancelText="Cancel"
confirmLoading={bannerForm.isSubmitting}
confirmText="Update"
disabled={bannerForm.isSubmitting}
onCancel={onCancel}
onConfirm={bannerForm.handleSubmit}
/>
</DialogActions>
</Dialog>
);
};
const styles = {
dialogWrapper: (theme) => ({
"& .MuiPaper-root": {
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
width: "100%",
maxWidth: 500,
},
"& .MuiDialogActions-spacing": {
padding: "0 40px 40px",
},
}),
dialogContent: (theme) => ({
color: theme.palette.text.secondary,
padding: "40px 40px 20px",
}),
dialogTitle: (theme) => ({
margin: 0,
marginBottom: 16,
color: theme.palette.text.primary,
fontWeight: 400,
fontSize: 20,
}),
settingName: (theme) => ({
marginTop: 0,
marginBottom: 8,
color: theme.palette.text.primary,
fontSize: 16,
lineHeight: "150%",
fontWeight: 600,
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@@ -0,0 +1,77 @@
import type { Interpolation, Theme } from "@emotion/react";
import Checkbox from "@mui/material/Checkbox";
import TableCell from "@mui/material/TableCell";
import TableRow from "@mui/material/TableRow";
import type { FC } from "react";
import type { BannerConfig } from "api/typesGenerated";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
interface NotificationBannerItemProps {
enabled: boolean;
backgroundColor?: string;
message?: string;
onUpdate: (banner: Partial<BannerConfig>) => Promise<void>;
onEdit: () => void;
onDelete: () => void;
}
export const NotificationBannerItem: FC<NotificationBannerItemProps> = ({
enabled,
backgroundColor = "#004852",
message,
onUpdate,
onEdit,
onDelete,
}) => {
return (
<TableRow>
<TableCell>
<Checkbox
size="small"
checked={enabled}
onClick={() => void onUpdate({ enabled: !enabled })}
/>
</TableCell>
<TableCell css={!enabled && styles.disabled}>
{message || <em>No message</em>}
</TableCell>
<TableCell>
<div css={styles.colorSample} style={{ backgroundColor }}></div>
</TableCell>
<TableCell>
<MoreMenu>
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem onClick={() => onEdit()}>Edit&hellip;</MoreMenuItem>
<MoreMenuItem onClick={() => onDelete()} danger>
Delete&hellip;
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</TableCell>
</TableRow>
);
};
const styles = {
disabled: (theme) => ({
color: theme.roles.inactive.fill.outline,
}),
colorSample: {
width: 24,
height: 24,
borderRadius: 4,
},
} satisfies Record<string, Interpolation<Theme>>;

View File

@@ -0,0 +1,202 @@
import { type CSSObject, useTheme } from "@emotion/react";
import AddIcon from "@mui/icons-material/AddOutlined";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { type FC, useState } from "react";
import type { BannerConfig } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Stack } from "components/Stack/Stack";
import { NotificationBannerDialog } from "./NotificationBannerDialog";
import { NotificationBannerItem } from "./NotificationBannerItem";
interface NotificationBannerSettingsProps {
isEntitled: boolean;
notificationBanners: readonly BannerConfig[];
onSubmit: (banners: readonly BannerConfig[]) => Promise<void>;
}
export const NotificationBannerSettings: FC<
NotificationBannerSettingsProps
> = ({ isEntitled, notificationBanners, onSubmit }) => {
const theme = useTheme();
const [banners, setBanners] = useState(notificationBanners);
const [editingBannerId, setEditingBannerId] = useState<number | null>(null);
const [deletingBannerId, setDeletingBannerId] = useState<number | null>(null);
const addBanner = () => {
setBanners([
...banners,
{ enabled: true, message: "", background_color: "#004852" },
]);
setEditingBannerId(banners.length);
};
const updateBanner = (i: number, banner: Partial<BannerConfig>) => {
const newBanners = [...banners];
newBanners[i] = { ...banners[i], ...banner };
setBanners(newBanners);
return newBanners;
};
const removeBanner = (i: number) => {
const newBanners = [...banners];
newBanners.splice(i, 1);
setBanners(newBanners);
return newBanners;
};
const editingBanner = editingBannerId !== null && banners[editingBannerId];
const deletingBanner = deletingBannerId !== null && banners[deletingBannerId];
// If we're not editing a new banner, remove all empty banners. This makes canceling the
// "new" dialog more intuitive, by not persisting an empty banner.
if (editingBannerId === null && banners.some((banner) => !banner.message)) {
setBanners(banners.filter((banner) => banner.message));
}
return (
<>
<div
css={{
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
marginTop: 32,
overflow: "hidden",
}}
>
<div css={{ padding: "24px 24px 0" }}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
>
<h3
css={{
fontSize: 20,
margin: 0,
fontWeight: 600,
}}
>
Notification Banners
</h3>
<Button
disabled={!isEntitled}
onClick={() => addBanner()}
startIcon={<AddIcon />}
>
New
</Button>
</Stack>
<div
css={{
color: theme.palette.text.secondary,
fontSize: 14,
marginTop: 8,
}}
>
Display message banners to all users.
</div>
<div
css={[
theme.typography.body2 as CSSObject,
{ paddingTop: 16, margin: "0 -32px" },
]}
>
<TableContainer css={{ borderRadius: 0, borderBottom: "none" }}>
<Table>
<TableHead>
<TableRow>
<TableCell width="1%">Enabled</TableCell>
<TableCell>Message</TableCell>
<TableCell width="2%">Color</TableCell>
<TableCell width="1%" />
</TableRow>
</TableHead>
<TableBody>
{!isEntitled || banners.length < 1 ? (
<TableCell colSpan={999}>
<EmptyState
css={{ minHeight: 160 }}
message="No notification banners"
/>
</TableCell>
) : (
banners.map((banner, i) => (
<NotificationBannerItem
key={banner.message}
enabled={banner.enabled && Boolean(banner.message)}
backgroundColor={banner.background_color}
message={banner.message}
onEdit={() => setEditingBannerId(i)}
onUpdate={async (banner) => {
const newBanners = updateBanner(i, banner);
await onSubmit(newBanners);
}}
onDelete={() => setDeletingBannerId(i)}
/>
))
)}
</TableBody>
</Table>
</TableContainer>
</div>
</div>
{!isEntitled && (
<footer
css={[
theme.typography.body2 as CSSObject,
{
background: theme.palette.background.paper,
padding: "16px 24px",
},
]}
>
<div css={{ color: theme.palette.text.secondary }}>
<p>
Your license does not include Service Banners.{" "}
<Link href="mailto:sales@coder.com">Contact sales</Link> to
learn more.
</p>
</div>
</footer>
)}
</div>
{editingBanner && (
<NotificationBannerDialog
banner={editingBanner}
onCancel={() => setEditingBannerId(null)}
onUpdate={async (banner) => {
const newBanners = updateBanner(editingBannerId, banner);
setEditingBannerId(null);
await onSubmit(newBanners);
}}
/>
)}
{deletingBanner && (
<ConfirmDialog
type="delete"
open
title="Delete this banner?"
description={deletingBanner.message}
onClose={() => setDeletingBannerId(null)}
onConfirm={async () => {
const newBanners = removeBanner(deletingBannerId);
setDeletingBannerId(null);
await onSubmit(newBanners);
}}
/>
)}
</>
);
};

View File

@@ -12,8 +12,7 @@ interface FieldsetProps {
isSubmitting?: boolean;
}
export const Fieldset: FC<FieldsetProps> = (props) => {
const {
export const Fieldset: FC<FieldsetProps> = ({
title,
subtitle,
children,
@@ -21,7 +20,7 @@ export const Fieldset: FC<FieldsetProps> = (props) => {
button,
onSubmit,
isSubmitting,
} = props;
}) => {
const theme = useTheme();
return (
@@ -30,6 +29,7 @@ export const Fieldset: FC<FieldsetProps> = (props) => {
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
marginTop: 32,
overflow: "hidden",
}}
onSubmit={onSubmit}
>

View File

@@ -8,12 +8,6 @@ import type { WorkspacePermissions } from "./permissions";
import { Workspace } from "./Workspace";
import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection";
const MockedAppearance = {
config: Mocks.MockAppearanceConfig,
isPreview: false,
setPreview: () => {},
};
const permissions: WorkspacePermissions = {
readWorkspace: true,
updateWorkspace: true,
@@ -43,7 +37,7 @@ const meta: Meta<typeof Workspace> = {
value={{
entitlements: Mocks.MockEntitlementsWithScheduling,
experiments: Mocks.MockExperiments,
appearance: MockedAppearance,
appearance: Mocks.MockAppearanceConfig,
}}
>
<ProxyContext.Provider

View File

@@ -13,7 +13,7 @@ import { Margins } from "components/Margins/Margins";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useEffectEvent } from "hooks/hookPolyfills";
import { Navbar } from "modules/dashboard/Navbar/Navbar";
import { ServiceBanner } from "modules/dashboard/ServiceBanner/ServiceBanner";
import { NotificationBanners } from "modules/dashboard/NotificationBanners/NotificationBanners";
import { workspaceChecks, type WorkspacePermissions } from "./permissions";
import { WorkspaceReadyPage } from "./WorkspaceReadyPage";
@@ -106,7 +106,7 @@ export const WorkspacePage: FC = () => {
return (
<>
<ServiceBanner />
<NotificationBanners />
<div css={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Navbar />
{pageError ? (

View File

@@ -91,12 +91,6 @@ const allWorkspaces = [
...Object.values(additionalWorkspaces),
];
const MockedAppearance = {
config: MockAppearanceConfig,
isPreview: false,
setPreview: () => {},
};
type FilterProps = ComponentProps<typeof WorkspacesPageView>["filterProps"];
const defaultFilterProps = getDefaultFilterProps<FilterProps>({
@@ -153,7 +147,7 @@ const meta: Meta<typeof WorkspacesPageView> = {
value={{
entitlements: MockEntitlementsWithScheduling,
experiments: MockExperiments,
appearance: MockedAppearance,
appearance: MockAppearanceConfig,
}}
>
<Story />

View File

@@ -2355,6 +2355,7 @@ export const MockAppearanceConfig: TypesGen.AppearanceConfig = {
service_banner: {
enabled: false,
},
notification_banners: [],
};
export const MockWorkspaceBuildParameter1: TypesGen.WorkspaceBuildParameter = {

View File

@@ -28,11 +28,7 @@ export const withDashboardProvider = (
value={{
entitlements,
experiments,
appearance: {
config: MockAppearanceConfig,
isPreview: false,
setPreview: () => {},
},
appearance: MockAppearanceConfig,
}}
>
<Story />

View File

@@ -6,7 +6,7 @@ import (
const (
CurrentMajor = 2
CurrentMinor = 0
CurrentMinor = 1
)
var CurrentVersion = apiversion.New(CurrentMajor, CurrentMinor).WithBackwardCompat(1)

View File

@@ -177,7 +177,6 @@ func handleTestSubprocess(t *testing.T) {
testName += *clientName
}
//nolint:parralleltest
t.Run(testName, func(t *testing.T) {
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
switch *role {