You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by oc...@apache.org on 2021/03/18 23:18:21 UTC

[trafficcontrol] branch master updated: Improves cdn notifications functionality (#5643)

This is an automated email from the ASF dual-hosted git repository.

ocket8888 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new 6959ec6  Improves cdn notifications functionality (#5643)
6959ec6 is described below

commit 6959ec6ae29ccc403ce288f44d0f1c3decaaa4bd
Author: Jeremy Mitchell <mi...@users.noreply.github.com>
AuthorDate: Thu Mar 18 17:18:08 2021 -0600

    Improves cdn notifications functionality (#5643)
    
    * creates a drop down for cdn notifications
    
    * adds cdn notifications tables and provides access to them
    
    * adds the ability to create notifications
    
    * adds ability to delete notifications
    
    * show the ALL cdn in the header bar
    
    * getting order or cdns and notifications right
    
    * allows the creation of multiple notifications for a cdn
    
    * fixes cdn notification tests
    
    * allows the user to dismiss notifications and localstorage will remember that dismissal
    
    * fixes goose down to put the cdn_notification table back like it was before
    
    * makes notification a required property
    
    * fixes formatting
    
    * uses ng-if rather than ng-show
    
    * changes anchor to button
---
 docs/source/api/v4/cdn_notifications.rst           |  14 +-
 lib/go-tc/cdn_notification.go                      |   6 +-
 ...2021031400000000_cdn_notifications_multiple.sql |  50 +++
 .../testing/api/v4/cdnnotifications_test.go        |  16 +-
 .../cdnnotification/cdnnotifications.go            |  38 ++-
 traffic_ops/v4-client/cdn_notifications.go         |   7 +-
 traffic_portal/app/src/app.js                      |   5 +
 .../common/modules/form/cdn/FormCDNController.js   |   4 +
 .../src/common/modules/form/cdn/form.cdn.tpl.html  |   2 +
 .../src/common/modules/header/HeaderController.js  |  80 +----
 .../app/src/common/modules/header/header.tpl.html  |  28 +-
 .../common/modules/navigation/navigation.tpl.html  |   1 +
 .../notifications/NotificationsController.js       |  19 +-
 .../modules/notifications/notifications.tpl.html   |   3 +-
 .../TableCDNNotificationsController.js             |  29 ++
 .../common/modules/table/cdnNotifications/index.js |  21 ++
 .../table.cdnNotifications.tpl.html                |  76 +++++
 .../modules/table/cdns/TableCDNsController.js      |   6 +
 .../notifications/TableNotificationsController.js  | 345 +++++++++++++++++++++
 .../common/modules/table/notifications/index.js    |  21 ++
 .../notifications/table.notifications.tpl.html     |  74 +++++
 .../modules/private/cdns/notifications/index.js    |  51 +++
 .../app/src/modules/private/notifications/index.js |  34 ++
 .../modules/private/notifications/list/index.js    |  45 +++
 .../private}/notifications/notifications.tpl.html  |   6 +-
 25 files changed, 865 insertions(+), 116 deletions(-)

diff --git a/docs/source/api/v4/cdn_notifications.rst b/docs/source/api/v4/cdn_notifications.rst
index 1da1de4..bdad91f 100644
--- a/docs/source/api/v4/cdn_notifications.rst
+++ b/docs/source/api/v4/cdn_notifications.rst
@@ -35,9 +35,11 @@ Request Structure
 	+------------+----------+-----------------------------------------------------------------------------------------------------+
 	| Parameter  | Required | Description                                                                                         |
 	+============+==========+=====================================================================================================+
-	| cdn        | no       | The CDN name of the notification you wish to retrieve.                                              |
+	| cdn        | no       | The CDN name of the notifications you wish to retrieve.                                             |
 	+------------+----------+-----------------------------------------------------------------------------------------------------+
-	| user       | no       | The username of the user responsible for creating the CDN notification.                             |
+	| id         | no       | The integral, unique identifier of the notification you wish to retrieve.                           |
+	+------------+----------+-----------------------------------------------------------------------------------------------------+
+	| user       | no       | The username of the user responsible for creating the CDN notifications.                            |
 	+------------+----------+-----------------------------------------------------------------------------------------------------+
 
 .. code-block:: http
@@ -52,6 +54,7 @@ Request Structure
 
 Response Structure
 ------------------
+:id:			The integral, unique identifier of the notification
 :cdn:			The name of the CDN to which the notification belongs to
 :lastUpdated:	The time and date this server entry was last updated in an ISO-like format
 :notification:	The content of the notification
@@ -75,6 +78,7 @@ Response Structure
 
 	{ "response": [
 		{
+			"id": 42,
 			"cdn": "cdn1",
 			"lastUpdated": "2019-12-02 21:49:08+00",
 			"notification": "the content of the notification",
@@ -113,6 +117,7 @@ Request Structure
 
 Response Structure
 ------------------
+:id:			The integral, unique identifier of the notification
 :cdn:			The name of the CDN to which the notification belongs to
 :lastUpdated:	The time and date this server entry was last updated in an ISO-like format
 :notification:	The content of the notification
@@ -144,6 +149,7 @@ Response Structure
 		],
 	"response":
 		{
+			"id": 42,
 			"cdn": "cdn1",
 			"lastUpdated": "2019-12-02 21:49:08+00",
 			"notification": "the content of the notification",
@@ -166,13 +172,13 @@ Request Structure
 	+------------+----------+-----------------------------------------------------------------------------------------------------+
 	| Parameter  | Required | Description                                                                                         |
 	+============+==========+=====================================================================================================+
-	| cdn        | yes      | The CDN name of the notification you wish to delete.                                                |
+	| id         | yes      | The integral, unique identifier of the notification you wish to delete.                             |
 	+------------+----------+-----------------------------------------------------------------------------------------------------+
 
 .. code-block:: http
 	:caption: Request Example
 
-	DELETE /api/4.0/cdn_notifications?cdn=cdn1 HTTP/1.1
+	DELETE /api/4.0/cdn_notifications?id=42 HTTP/1.1
 	User-Agent: python-requests/2.22.0
 	Accept-Encoding: gzip, deflate
 	Accept: */*
diff --git a/lib/go-tc/cdn_notification.go b/lib/go-tc/cdn_notification.go
index 30d418d..f922e81 100644
--- a/lib/go-tc/cdn_notification.go
+++ b/lib/go-tc/cdn_notification.go
@@ -44,16 +44,18 @@ type CDNNotificationRequest struct {
 
 // CDNNotification is a notification created for a specific CDN.
 type CDNNotification struct {
+	ID           int       `json:"id" db:"id"`
 	CDN          string    `json:"cdn" db:"cdn"`
 	LastUpdated  time.Time `json:"lastUpdated" db:"last_updated"`
-	Notification *string   `json:"notification" db:"notification"`
+	Notification string    `json:"notification" db:"notification"`
 	User         string    `json:"user" db:"user"`
 }
 
 // Validate validates the CDNNotificationRequest request is valid for creation.
 func (n *CDNNotificationRequest) Validate(tx *sql.Tx) error {
 	errs := validation.Errors{
-		"cdn": validation.Validate(n.CDN, validation.Required),
+		"cdn":          validation.Validate(n.CDN, validation.Required),
+		"notification": validation.Validate(n.Notification, validation.Required),
 	}
 	return util.JoinErrs(tovalidate.ToErrors(errs))
 }
diff --git a/traffic_ops/app/db/migrations/2021031400000000_cdn_notifications_multiple.sql b/traffic_ops/app/db/migrations/2021031400000000_cdn_notifications_multiple.sql
new file mode 100644
index 0000000..5347bb9
--- /dev/null
+++ b/traffic_ops/app/db/migrations/2021031400000000_cdn_notifications_multiple.sql
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership.  The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+-- +goose Up
+-- SQL in section 'Up' is executed when this migration is applied
+DROP TABLE IF EXISTS cdn_notification;
+
+CREATE TABLE cdn_notification (
+    id BIGSERIAL PRIMARY KEY,
+    cdn text NOT NULL,
+    "user" text NOT NULL,
+    notification text NOT NULL,
+    last_updated timestamp with time zone DEFAULT now() NOT NULL,
+    CONSTRAINT fk_notification_cdn FOREIGN KEY (cdn) REFERENCES cdn(name) ON DELETE CASCADE ON UPDATE CASCADE,
+    CONSTRAINT fk_notification_user FOREIGN KEY ("user") REFERENCES tm_user(username) ON DELETE CASCADE ON UPDATE CASCADE
+);
+DROP TRIGGER IF EXISTS on_update_current_timestamp ON cdn_notification;
+CREATE TRIGGER on_update_current_timestamp BEFORE UPDATE ON cdn_notification FOR EACH ROW EXECUTE PROCEDURE on_update_current_timestamp_last_updated();
+
+
+-- +goose Down
+-- SQL section 'Down' is executed when this migration is rolled back
+DROP TABLE IF EXISTS cdn_notification;
+
+CREATE TABLE cdn_notification (
+    cdn text NOT NULL,
+    "user" text NOT NULL,
+    notification text,
+    last_updated timestamp with time zone DEFAULT now() NOT NULL,
+    CONSTRAINT pk_cdn_notification PRIMARY KEY (cdn),
+    CONSTRAINT fk_notification_cdn FOREIGN KEY (cdn) REFERENCES cdn(name) ON DELETE CASCADE ON UPDATE CASCADE,
+    CONSTRAINT fk_notification_user FOREIGN KEY ("user") REFERENCES tm_user(username) ON DELETE CASCADE ON UPDATE CASCADE
+);
+DROP TRIGGER IF EXISTS on_update_current_timestamp ON cdn_notification;
+CREATE TRIGGER on_update_current_timestamp BEFORE UPDATE ON cdn_notification FOR EACH ROW EXECUTE PROCEDURE on_update_current_timestamp_last_updated();
+
diff --git a/traffic_ops/testing/api/v4/cdnnotifications_test.go b/traffic_ops/testing/api/v4/cdnnotifications_test.go
index 80ae9d4..13f0a05 100644
--- a/traffic_ops/testing/api/v4/cdnnotifications_test.go
+++ b/traffic_ops/testing/api/v4/cdnnotifications_test.go
@@ -36,8 +36,8 @@ func GetTestCDNotifications(t *testing.T) {
 		if len(resp) > 0 {
 			respNotification := resp[0]
 			expectedNotification := "test notification: " + cdn.Name
-			if respNotification.Notification != nil && *respNotification.Notification != expectedNotification {
-				t.Errorf("expected notification does not match actual: %s, expected: %s", *respNotification.Notification, expectedNotification)
+			if respNotification.Notification != expectedNotification {
+				t.Errorf("expected notification does not match actual: %s, expected: %s", respNotification.Notification, expectedNotification)
 			}
 		}
 	}
@@ -54,9 +54,17 @@ func CreateTestCDNNotifications(t *testing.T) {
 
 func DeleteTestCDNNotifications(t *testing.T) {
 	for _, cdn := range testData.CDNs {
-		_, _, err := TOSession.DeleteCDNNotification(cdn.Name)
+		// Retrieve the notifications for a cdn
+		resp, _, err := TOSession.GetCDNNotifications(cdn.Name, nil)
 		if err != nil {
-			t.Errorf("cannot DELETE CDN notification: '%s' %v", cdn.Name, err)
+			t.Errorf("cannot GET notifications for a CDN: %v - %v", cdn.Name, err)
+		}
+		if len(resp) > 0 {
+			respNotification := resp[0]
+			_, _, err := TOSession.DeleteCDNNotification(respNotification.ID)
+			if err != nil {
+				t.Errorf("cannot DELETE CDN notification by ID: '%d' %v", respNotification.ID, err)
+			}
 		}
 	}
 }
diff --git a/traffic_ops/traffic_ops_golang/cdnnotification/cdnnotifications.go b/traffic_ops/traffic_ops_golang/cdnnotification/cdnnotifications.go
index 60d1214..66c36d4 100644
--- a/traffic_ops/traffic_ops_golang/cdnnotification/cdnnotifications.go
+++ b/traffic_ops/traffic_ops_golang/cdnnotification/cdnnotifications.go
@@ -32,7 +32,8 @@ import (
 )
 
 const readQuery = `
-SELECT cn.cdn, 
+SELECT cn.id,
+	cn.cdn, 
 	cn.last_updated,
 	cn.user, 
 	cn.notification 
@@ -44,19 +45,21 @@ INNER JOIN tm_user ON tm_user.username = cn.user
 const insertQuery = `
 INSERT INTO cdn_notification (cdn, "user", notification)
 VALUES ($1, $2, $3)
-RETURNING cdn_notification.cdn,
-          cdn_notification.last_updated,
-          cdn_notification.user,
-          cdn_notification.notification
+RETURNING cdn_notification.id,
+cdn_notification.cdn,
+cdn_notification.last_updated,
+cdn_notification.user,
+cdn_notification.notification
 `
 
 const deleteQuery = `
 DELETE FROM cdn_notification
-WHERE cdn_notification.cdn = $1
-RETURNING cdn_notification.cdn,
-          cdn_notification.last_updated,
-          cdn_notification.user,
-          cdn_notification.notification
+WHERE cdn_notification.id = $1
+RETURNING cdn_notification.id,
+cdn_notification.cdn,
+cdn_notification.last_updated,
+cdn_notification.user,
+cdn_notification.notification
 `
 
 // Read is the handler for GET requests to /cdn_notifications.
@@ -72,6 +75,7 @@ func Read(w http.ResponseWriter, r *http.Request) {
 	cdnNotifications := []tc.CDNNotification{}
 
 	queryParamsToQueryCols := map[string]dbhelpers.WhereColumnInfo{
+		"id":   dbhelpers.WhereColumnInfo{"cn.id", api.IsInt},
 		"cdn":  dbhelpers.WhereColumnInfo{"cdn.name", nil},
 		"user": dbhelpers.WhereColumnInfo{"tm_user.username", nil},
 	}
@@ -99,7 +103,7 @@ func Read(w http.ResponseWriter, r *http.Request) {
 
 	for rows.Next() {
 		var n tc.CDNNotification
-		if err = rows.Scan(&n.CDN, &n.LastUpdated, &n.User, &n.Notification); err != nil {
+		if err = rows.Scan(&n.ID, &n.CDN, &n.LastUpdated, &n.User, &n.Notification); err != nil {
 			api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, errors.New("scanning cdn notifications: "+err.Error()))
 			return
 		}
@@ -126,14 +130,14 @@ func Create(w http.ResponseWriter, r *http.Request) {
 	}
 
 	var resp tc.CDNNotification
-	err := tx.QueryRow(insertQuery, req.CDN, inf.User.UserName, req.Notification).Scan(&resp.CDN, &resp.LastUpdated, &resp.User, &resp.Notification)
+	err := tx.QueryRow(insertQuery, req.CDN, inf.User.UserName, req.Notification).Scan(&resp.ID, &resp.CDN, &resp.LastUpdated, &resp.User, &resp.Notification)
 	if err != nil {
 		userErr, sysErr, errCode = api.ParseDBError(err)
 		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
 		return
 	}
 
-	changeLogMsg := fmt.Sprintf("CDN_NOTIFICATION: %s, CDN: %s, ACTION: Created", *resp.Notification, resp.CDN)
+	changeLogMsg := fmt.Sprintf("CDN_NOTIFICATION: %s, CDN: %s, ACTION: Created", resp.Notification, resp.CDN)
 	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, tx)
 
 	alertMsg := fmt.Sprintf("CDN notification created [ User = %s ] for CDN: %s", resp.User, resp.CDN)
@@ -143,7 +147,7 @@ func Create(w http.ResponseWriter, r *http.Request) {
 
 // Delete is the handler for DELETE requests to /cdn_notifications.
 func Delete(w http.ResponseWriter, r *http.Request) {
-	inf, sysErr, userErr, errCode := api.NewInfo(r, []string{"cdn"}, nil)
+	inf, userErr, sysErr, errCode := api.NewInfo(r, []string{"id"}, []string{"id"})
 	tx := inf.Tx.Tx
 	if sysErr != nil || userErr != nil {
 		api.HandleErr(w, r, tx, errCode, userErr, sysErr)
@@ -167,10 +171,10 @@ func deleteCDNNotification(inf *api.APIInfo) (tc.Alert, tc.CDNNotification, erro
 	var alert tc.Alert
 	var result tc.CDNNotification
 
-	err := inf.Tx.Tx.QueryRow(deleteQuery, inf.Params["cdn"]).Scan(&result.CDN, &result.LastUpdated, &result.User, &result.Notification)
+	err := inf.Tx.Tx.QueryRow(deleteQuery, inf.Params["id"]).Scan(&result.ID, &result.CDN, &result.LastUpdated, &result.User, &result.Notification)
 	if err != nil {
 		if err == sql.ErrNoRows {
-			userErr = fmt.Errorf("No CDN Notification for %s", inf.Params["cdn"])
+			userErr = fmt.Errorf("No CDN Notification for %s", inf.Params["id"])
 			statusCode = http.StatusNotFound
 		} else {
 			userErr, sysErr, statusCode = api.ParseDBError(err)
@@ -179,7 +183,7 @@ func deleteCDNNotification(inf *api.APIInfo) (tc.Alert, tc.CDNNotification, erro
 		return alert, result, userErr, sysErr, statusCode
 	}
 
-	changeLogMsg := fmt.Sprintf("CDN_NOTIFICATION: %s, CDN: %s, ACTION: Deleted", *result.Notification, result.CDN)
+	changeLogMsg := fmt.Sprintf("CDN_NOTIFICATION: %s, CDN: %s, ACTION: Deleted", result.Notification, result.CDN)
 	api.CreateChangeLogRawTx(api.ApiChange, changeLogMsg, inf.User, inf.Tx.Tx)
 
 	alertMsg := fmt.Sprintf("CDN notification deleted [ User = %s ] for CDN: %s", result.User, result.CDN)
diff --git a/traffic_ops/v4-client/cdn_notifications.go b/traffic_ops/v4-client/cdn_notifications.go
index 8925668..7cd563c 100644
--- a/traffic_ops/v4-client/cdn_notifications.go
+++ b/traffic_ops/v4-client/cdn_notifications.go
@@ -19,6 +19,7 @@ import (
 	"fmt"
 	"net/http"
 	"net/url"
+	"strconv"
 
 	"github.com/apache/trafficcontrol/lib/go-tc"
 	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
@@ -45,11 +46,11 @@ func (to *Session) CreateCDNNotification(notification tc.CDNNotificationRequest)
 	return alerts, reqInf, err
 }
 
-// DeleteCDNNotification deletes a CDN Notification by CDN name.
-func (to *Session) DeleteCDNNotification(cdnName string) (tc.Alerts, toclientlib.ReqInf, error) {
+// DeleteCDNNotification deletes a CDN Notification by notification ID.
+func (to *Session) DeleteCDNNotification(id int) (tc.Alerts, toclientlib.ReqInf, error) {
 	var alerts tc.Alerts
 	params := url.Values{}
-	params.Add("cdn", cdnName)
+	params.Add("id", strconv.Itoa(id))
 	route := fmt.Sprintf("%s?%s", APICDNNotifications, params.Encode())
 	reqInf, err := to.del(route, nil, &alerts)
 	return alerts, reqInf, err
diff --git a/traffic_portal/app/src/app.js b/traffic_portal/app/src/app.js
index 0e47f22..fd624d0 100644
--- a/traffic_portal/app/src/app.js
+++ b/traffic_portal/app/src/app.js
@@ -88,6 +88,7 @@ var trafficPortal = angular.module('trafficPortal', [
         require('./modules/private/cdns/federations/users').name,
         require('./modules/private/cdns/list').name,
         require('./modules/private/cdns/new').name,
+        require('./modules/private/cdns/notifications').name,
         require('./modules/private/cdns/profiles').name,
         require('./modules/private/cdns/servers').name,
         require('./modules/private/changeLogs').name,
@@ -147,6 +148,8 @@ var trafficPortal = angular.module('trafficPortal', [
         require('./modules/private/jobs').name,
         require('./modules/private/jobs/list').name,
         require('./modules/private/jobs/new').name,
+        require('./modules/private/notifications').name,
+        require('./modules/private/notifications/list').name,
         require('./modules/private/origins').name,
         require('./modules/private/origins/edit').name,
         require('./modules/private/origins/list').name,
@@ -366,6 +369,7 @@ var trafficPortal = angular.module('trafficPortal', [
         require('./common/modules/table/cdnFederations').name,
         require('./common/modules/table/cdnFederationDeliveryServices').name,
         require('./common/modules/table/cdnFederationUsers').name,
+        require('./common/modules/table/cdnNotifications').name,
         require('./common/modules/table/cdnProfiles').name,
         require('./common/modules/table/cdnServers').name,
         require('./common/modules/table/coordinates').name,
@@ -385,6 +389,7 @@ var trafficPortal = angular.module('trafficPortal', [
         require('./common/modules/table/endpoints').name,
         require('./common/modules/table/federationResolvers').name,
         require('./common/modules/table/jobs').name,
+        require('./common/modules/table/notifications').name,
         require('./common/modules/table/origins').name,
         require('./common/modules/table/physLocations').name,
         require('./common/modules/table/physLocationServers').name,
diff --git a/traffic_portal/app/src/common/modules/form/cdn/FormCDNController.js b/traffic_portal/app/src/common/modules/form/cdn/FormCDNController.js
index f495032..fe19845 100644
--- a/traffic_portal/app/src/common/modules/form/cdn/FormCDNController.js
+++ b/traffic_portal/app/src/common/modules/form/cdn/FormCDNController.js
@@ -58,6 +58,10 @@ var FormCDNController = function(cdn, $scope, $location, $uibModal, formUtils, s
         $location.path($location.path() + '/delivery-services');
     };
 
+    $scope.viewNotifications = function() {
+        $location.path($location.path() + '/notifications');
+    };
+
     $scope.queueServerUpdates = function(cdn) {
         var params = {
             title: 'Queue Server Updates: ' + cdn.name,
diff --git a/traffic_portal/app/src/common/modules/form/cdn/form.cdn.tpl.html b/traffic_portal/app/src/common/modules/form/cdn/form.cdn.tpl.html
index 21ae568..534f591 100644
--- a/traffic_portal/app/src/common/modules/form/cdn/form.cdn.tpl.html
+++ b/traffic_portal/app/src/common/modules/form/cdn/form.cdn.tpl.html
@@ -47,6 +47,8 @@ under the License.
                     <li role="menuitem"><a ng-click="viewDeliveryServices()">View Delivery Services</a></li>
                     <li role="menuitem"><a ng-click="viewProfiles()">View Profiles</a></li>
                     <li role="menuitem"><a ng-click="viewServers()">View Servers</a></li>
+                    <li class="divider"></li>
+                    <li role="menuitem"><a ng-click="viewNotifications()">View Notifications</a></li>
                 </ul>
             </div>
         </div>
diff --git a/traffic_portal/app/src/common/modules/header/HeaderController.js b/traffic_portal/app/src/common/modules/header/HeaderController.js
index 8ca0666..9dec203 100644
--- a/traffic_portal/app/src/common/modules/header/HeaderController.js
+++ b/traffic_portal/app/src/common/modules/header/HeaderController.js
@@ -20,14 +20,10 @@
 var HeaderController = function($rootScope, $scope, $state, $uibModal, $location, $anchorScroll, locationUtils, permissionUtils, authService, trafficPortalService, changeLogService, cdnService, changeLogModel, userModel, propertiesModel) {
 
     let getCDNs = function(notifications) {
-        cdnService.getCDNs()
+        cdnService.getCDNs(true)
             .then(function(cdns) {
                 cdns.forEach(function(cdn) {
-                    const cdnNotification = notifications.find(function(notification){ return cdn.name === notification.cdn });
-                    if (cdnNotification) {
-                        cdn.notificationCreatedBy = cdnNotification.user;
-                        cdn.notification = cdnNotification.notification;
-                    }
+                    cdn.hasNotifications = notifications.find(function(notification){ return cdn.name === notification.cdn });
                 });
                 $scope.cdns = cdns;
             });
@@ -66,6 +62,16 @@ var HeaderController = function($rootScope, $scope, $state, $uibModal, $location
             });
     };
 
+    $scope.getNotifications = function(cdn) {
+        $scope.loadingNotifications = true;
+        $scope.notifications = [];
+        cdnService.getNotifications({ cdn: cdn.name })
+            .then(function(response) {
+                $scope.loadingNotifications = false;
+                $scope.notifications = response;
+            });
+    };
+
     $scope.getRelativeTime = function(date) {
         return moment(date).fromNow();
     };
@@ -78,68 +84,6 @@ var HeaderController = function($rootScope, $scope, $state, $uibModal, $location
         trafficPortalService.dbDump();
     };
 
-    $scope.toggleNotification = function(cdn) {
-        if (cdn.notificationCreatedBy) {
-            confirmDeleteNotification(cdn);
-        } else {
-            confirmCreateNotification(cdn);
-        }
-    };
-
-    let confirmCreateNotification = function(cdn) {
-        const params = {
-            title: 'Create Global ' + cdn.name + ' Notification',
-            message: 'What is the content of your global notification for the ' + cdn.name + ' CDN?'
-        };
-        const modalInstance = $uibModal.open({
-            templateUrl: 'common/modules/dialog/input/dialog.input.tpl.html',
-            controller: 'DialogInputController',
-            size: 'md',
-            resolve: {
-                params: function () {
-                    return params;
-                }
-            }
-        });
-        modalInstance.result.then(function(notification) {
-            cdnService.createNotification(cdn, notification).
-                then(
-                    function() {
-                        $rootScope.$broadcast('headerController::notificationCreated');
-                    }
-                );
-        }, function () {
-            // do nothing
-        });
-    };
-
-    let confirmDeleteNotification = function(cdn) {
-        const params = {
-            title: 'Delete Global ' + cdn.name + ' Notification',
-            message: 'Are you sure you want to delete the global notification for the ' + cdn.name + ' CDN? This will remove the notification from the view of all users.'
-        };
-        const modalInstance = $uibModal.open({
-            templateUrl: 'common/modules/dialog/confirm/dialog.confirm.tpl.html',
-            controller: 'DialogConfirmController',
-            size: 'md',
-            resolve: {
-                params: function () {
-                    return params;
-                }
-            }
-        });
-        modalInstance.result.then(function() {
-            cdnService.deleteNotification({ cdn: cdn.name }).
-                then(
-                    function() {
-                        $rootScope.$broadcast('headerController::notificationDeleted');
-                    }
-                );
-        }, function () {
-            // do nothing
-        });
-    };
-
     $scope.confirmQueueServerUpdates = function() {
         var params = {
             title: 'Queue Server Updates',
diff --git a/traffic_portal/app/src/common/modules/header/header.tpl.html b/traffic_portal/app/src/common/modules/header/header.tpl.html
index 004e249..0c63706 100644
--- a/traffic_portal/app/src/common/modules/header/header.tpl.html
+++ b/traffic_portal/app/src/common/modules/header/header.tpl.html
@@ -80,9 +80,31 @@ under the License.
                     <button type="button" class="btn btn-link" ng-if="hasCapability('cdns-snapshot')" ng-click="snapshot()"><i class="fa fa-camera"></i></button>
                 </div>
             </li>
-            <li ng-repeat="cdn in cdns">
-                <div class="btn-group" title="{{(cdn.notificationCreatedBy) ? 'Delete Global ' + cdn.name + ' Notification' : 'Create Global ' + cdn.name + ' Notification'}}">
-                    <button type="button" class="notification btn btn-link" ng-click="toggleNotification(cdn)">{{cdn.name}} <i class="fa" ng-class="{ 'fa-bell': cdn.notificationCreatedBy, 'fa-bell-o': !cdn.notificationCreatedBy }"></i> <i ng-show="cdn.notificationCreatedBy" class="fa fa-times"></i></button>
+            <li role="presentation" class="dropdown" ng-repeat="cdn in cdns | orderBy: '-name'">
+                <div class="btn-group" title="{{cdn.name}} Notifications" uib-dropdown is-open="{{cdn.name}}-notifications.isopen">
+                    <button id="{{cdn.name}}-notifications" type="button" class="btn btn-link" ng-click="getNotifications(cdn)" uib-dropdown-toggle>
+                        {{cdn.name}} <i class="fa" ng-class="{ 'fa-bell': cdn.hasNotifications, 'fa-bell-o': !cdn.hasNotifications }"></i>
+                    </button>
+                    <ul class="uib-dropdown-menu list-unstyled msg_list animated fadeInDown" role="menu">
+                        <li ng-if="loadingNotifications" style="text-align: center"><i class="fa fa-refresh fa-spin fa-1x fa-fw"></i></li>
+                        <li ng-if="notifications.length === 0">
+                            <div class="text-center">No {{cdn.name}} notifications</div>
+                        </li>
+                        <li ng-repeat="notification in notifications">
+                            <a ng-click="$event.stopPropagation()">
+                                <span>
+                                    <span>{{::notification.user}}</span>
+                                    <span class="time">{{::getRelativeTime(notification.lastUpdated)}}</span>
+                                </span>
+                                <span class="message">{{::notification.notification}}</span>
+                            </a>
+                        </li>
+                        <li>
+                            <div class="text-center">
+                                <button type="button" class="btn btn-link" title="Manage {{cdn.name}} notifications" ng-click="navigateToPath('/cdns/' + cdn.id + '/notifications')"><strong>Manage {{cdn.name}} notifications <i class="fa fa-angle-right"></i></strong></button>
+                            </div>
+                        </li>
+                    </ul>
                 </div>
             </li>
         </ul>
diff --git a/traffic_portal/app/src/common/modules/navigation/navigation.tpl.html b/traffic_portal/app/src/common/modules/navigation/navigation.tpl.html
index d9c81d3..bf067e9 100644
--- a/traffic_portal/app/src/common/modules/navigation/navigation.tpl.html
+++ b/traffic_portal/app/src/common/modules/navigation/navigation.tpl.html
@@ -73,6 +73,7 @@ under the License.
                         <li class="side-menu-category-item" ng-if="hasCapability('users-read')" ng-class="{'current-page': isState('trafficPortal.private.users')}"><a href="/#!/users">Users</a></li>
                         <li class="side-menu-category-item" ng-if="hasCapability('tenants-read')" ng-class="{'current-page': isState('trafficPortal.private.tenants')}"><a href="/#!/tenants">Tenants</a></li>
                         <li class="side-menu-category-item" ng-if="hasCapability('roles-read')" ng-class="{'current-page': isState('trafficPortal.private.roles')}"><a href="/#!/roles">Roles</a></li>
+                        <li class="side-menu-category-item" ng-if="hasCapability('notifications-read')" ng-class="{'current-page': isState('trafficPortal.private.notifications')}"><a href="/#!/notifications">Notifications</a></li>
                         <li class="side-menu-category-item" ng-if="enforceCapabilities && hasCapability('capabilities-read')" ng-class="{'current-page': isState('trafficPortal.private.capabilities')}"><a href="/#!/capabilities">Capabilities</a></li>
                         <li class="side-menu-category-item" ng-if="enforceCapabilities && hasCapability('api-endpoints-read')" ng-class="{'current-page': isState('trafficPortal.private.endpoints')}"><a href="/#!/endpoints">API Endpoints</a></li>
                     </ul>
diff --git a/traffic_portal/app/src/common/modules/notifications/NotificationsController.js b/traffic_portal/app/src/common/modules/notifications/NotificationsController.js
index 6dc3ae8..a66a59c 100644
--- a/traffic_portal/app/src/common/modules/notifications/NotificationsController.js
+++ b/traffic_portal/app/src/common/modules/notifications/NotificationsController.js
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-var NotificationsController = function($scope, $rootScope, $interval, cdnService) {
+var NotificationsController = function($rootScope, $scope, $interval, cdnService) {
 
 	let interval;
 
@@ -40,16 +40,15 @@ var NotificationsController = function($scope, $rootScope, $interval, cdnService
 		}
 	};
 
-	$scope.$on("$destroy", function() {
-		killInterval();
-	});
+	$scope.dismissedNotifications = JSON.parse(localStorage.getItem("dismissed_notification_ids")) || [];
 
-	$rootScope.$on('headerController::notificationCreated', function() {
-		getNotifications();
-	});
+	$scope.dismissNotification = function(notification) {
+		$scope.dismissedNotifications.push(notification.id);
+		localStorage.setItem("dismissed_notification_ids", JSON.stringify($scope.dismissedNotifications));
+	};
 
-	$rootScope.$on('headerController::notificationDeleted', function() {
-		getNotifications();
+	$scope.$on("$destroy", function() {
+		killInterval();
 	});
 
 	let init = function () {
@@ -60,5 +59,5 @@ var NotificationsController = function($scope, $rootScope, $interval, cdnService
 
 };
 
-NotificationsController.$inject = ['$scope', '$rootScope', '$interval', 'cdnService'];
+NotificationsController.$inject = ['$rootScope', '$scope', '$interval', 'cdnService'];
 module.exports = NotificationsController;
diff --git a/traffic_portal/app/src/common/modules/notifications/notifications.tpl.html b/traffic_portal/app/src/common/modules/notifications/notifications.tpl.html
index 87ffaad..fc583e7 100644
--- a/traffic_portal/app/src/common/modules/notifications/notifications.tpl.html
+++ b/traffic_portal/app/src/common/modules/notifications/notifications.tpl.html
@@ -18,7 +18,8 @@ under the License.
 -->
 
 <div id="notificationsContainer">
-    <div class="alert alert-info" ng-repeat="n in notifications">
+    <div ng-if="!dismissedNotifications.includes(n.id)" class="alert alert-dismissable alert-info" ng-repeat="n in notifications | orderBy: 'cdn'">
+        <button type="button" class="close" data-dismiss="alert" aria-hidden="true" ng-click="dismissNotification(n)">&times;</button>
         <div ng-bind-html="n.cdn + ': ' + n.notification + ' (' + n.user + ')' | linky:'_blank'"></div>
     </div>
 </div>
diff --git a/traffic_portal/app/src/common/modules/table/cdnNotifications/TableCDNNotificationsController.js b/traffic_portal/app/src/common/modules/table/cdnNotifications/TableCDNNotificationsController.js
new file mode 100644
index 0000000..761e380
--- /dev/null
+++ b/traffic_portal/app/src/common/modules/table/cdnNotifications/TableCDNNotificationsController.js
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+var TableCDNNotificationsController = function(cdn, notifications, filter, $controller, $scope) {
+
+	// extends the TableNotificationsController to inherit common methods
+	angular.extend(this, $controller('TableNotificationsController', { tableName: 'cdnNotifications', notifications: notifications, filter: filter, $scope: $scope }));
+
+	$scope.cdn = cdn;
+};
+
+TableCDNNotificationsController.$inject = ['cdn', 'notifications', 'filter', '$controller', '$scope'];
+module.exports = TableCDNNotificationsController;
diff --git a/traffic_portal/app/src/common/modules/table/cdnNotifications/index.js b/traffic_portal/app/src/common/modules/table/cdnNotifications/index.js
new file mode 100644
index 0000000..23d8906
--- /dev/null
+++ b/traffic_portal/app/src/common/modules/table/cdnNotifications/index.js
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+module.exports = angular.module('trafficPortal.table.cdnNotifications', [])
+	.controller('TableCDNNotificationsController', require('./TableCDNNotificationsController'));
diff --git a/traffic_portal/app/src/common/modules/table/cdnNotifications/table.cdnNotifications.tpl.html b/traffic_portal/app/src/common/modules/table/cdnNotifications/table.cdnNotifications.tpl.html
new file mode 100644
index 0000000..2e41759
--- /dev/null
+++ b/traffic_portal/app/src/common/modules/table/cdnNotifications/table.cdnNotifications.tpl.html
@@ -0,0 +1,76 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+<div class="x_panel">
+    <div class="x_title">
+        <ol class="breadcrumb pull-left">
+            <li><a href="#!/cdns">CDNs</a></li>
+            <li><a ng-href="#!/cdns/{{cdn.id}}">{{::cdn.name}}</a></li>
+            <li class="active">Notifications</li>
+        </ol>
+        <div class="pull-right">
+            <div class="form-inline" role="search">
+                <input id="quickSearch" name="quickSearch" type="search" class="form-control text-input" placeholder="Quick search..." ng-model="quickSearch" ng-change="onQuickSearchChanged()" aria-label="Search"/>
+                <div class="input-group text-input">
+                    <span class="input-group-addon">
+                        <label for="pageSize">Page size</label>
+                    </span>
+                    <input id="pageSize" name="pageSize" type="number" min="1" class="form-control" placeholder="100" ng-model="pageSize" ng-change="onPageSizeChanged()" aria-label="Page Size"/>
+                </div>
+                <div id="toggleColumns" class="btn-group" role="group" title="Select Table Columns" uib-dropdown is-open="columnSettings.isopen">
+                    <button type="button" class="btn btn-default dropdown-toggle" uib-dropdown-toggle aria-haspopup="true" aria-expanded="false">
+                        <i class="fa fa-columns"></i>&nbsp;
+                        <span class="caret"></span>
+                    </button>
+                    <menu ng-click="$event.stopPropagation()" class="column-settings dropdown-menu-right dropdown-menu" uib-dropdown-menu>
+                        <li role="menuitem" ng-repeat="c in gridOptions.columnApi.getAllColumns() | orderBy:'colDef.headerName'">
+                            <div class="checkbox">
+                                <label><input type="checkbox" ng-checked="c.isVisible()" ng-click="toggleVisibility(c.colId)">{{::c.colDef.headerName}}</label>
+                            </div>
+                        </li>
+                    </menu>
+                </div>
+                <div class="btn-group" role="group" uib-dropdown is-open="more.isopen">
+                    <button name="moreBtn" type="button" class="btn btn-default dropdown-toggle" uib-dropdown-toggle aria-haspopup="true" aria-expanded="false">
+                        More&nbsp;
+                        <span class="caret"></span>
+                    </button>
+                    <ul class="dropdown-menu-right dropdown-menu" uib-dropdown-menu>
+                        <li role="menuitem"><button class="menu-item-button" type="button" ng-click="createNotification(cdn)">Create Notification</button></li>
+                        <li class="divider"></li>
+                        <li role="menuitem"><button class="menu-item-button" type="button" ng-click="clearTableFilters()">Clear Table Filters</button></li>
+                        <li role="menuitem"><button class="menu-item-button" type="button" ng-click="exportCSV()">Export CSV</button></li>
+                    </ul>
+                </div>
+            </div>
+        </div>
+        <div class="clearfix"></div>
+    </div>
+    <div class="x_content">
+        <div style="height: 740px;" ag-grid="gridOptions" class="ag-theme-alpine"></div>
+    </div>
+</div>
+
+<menu id="context-menu" class="dropdown-menu" ng-style="menuStyle" type="contextmenu" ng-show="showMenu">
+    <ul>
+        <li role="menuitem">
+            <button type="button" ng-click="confirmDeleteNotification(notification, $event)">Delete Notification</button>
+        </li>
+    </ul>
+</menu>
diff --git a/traffic_portal/app/src/common/modules/table/cdns/TableCDNsController.js b/traffic_portal/app/src/common/modules/table/cdns/TableCDNsController.js
index 0127172..dc17547 100644
--- a/traffic_portal/app/src/common/modules/table/cdns/TableCDNsController.js
+++ b/traffic_portal/app/src/common/modules/table/cdns/TableCDNsController.js
@@ -173,6 +173,12 @@ var TableCDNsController = function(cdns, $location, $scope, $state, $uibModal, $
             click: function ($itemScope) {
                 locationUtils.navigateToPath('/cdns/' + $itemScope.cdn.id + '/servers');
             }
+        },
+        {
+            text: 'Manage Notifications',
+            click: function ($itemScope) {
+                locationUtils.navigateToPath('/cdns/' + $itemScope.cdn.id + '/notifications');
+            }
         }
     ];
 
diff --git a/traffic_portal/app/src/common/modules/table/notifications/TableNotificationsController.js b/traffic_portal/app/src/common/modules/table/notifications/TableNotificationsController.js
new file mode 100644
index 0000000..3581a47
--- /dev/null
+++ b/traffic_portal/app/src/common/modules/table/notifications/TableNotificationsController.js
@@ -0,0 +1,345 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+var TableNotificationsController = function(tableName, notifications, filter, $scope, $state, $uibModal, $document, dateUtils, cdnService) {
+
+	/**
+	 * Gets value to display a default tooltip.
+	 */
+	function defaultTooltip(params) {
+		return params.value;
+	}
+
+	function dateCellFormatter(params) {
+		return params.value.toUTCString();
+	}
+
+	let columns = [
+		{
+			headerName: "Created (UTC)",
+			field: "lastUpdated",
+			hide: false,
+			filter: "agDateColumnFilter",
+			tooltipValueGetter: dateCellFormatter,
+			valueFormatter: dateCellFormatter
+		},
+		{
+			headerName: "User",
+			field: "user",
+			hide: false
+		},
+		{
+			headerName: "CDN",
+			field: "cdn",
+			hide: false
+		},
+		{
+			headerName: "Notification",
+			field: "notification",
+			hide: false
+		}
+	];
+
+	/** All of the notifications - lastUpdated fields converted to actual Date */
+	$scope.notifications = notifications.map(
+		function(x) {
+			x.lastUpdated = x.lastUpdated ? new Date(x.lastUpdated.replace("+00", "Z")) : x.lastUpdated;
+		});
+
+	$scope.quickSearch = '';
+
+	$scope.pageSize = 100;
+
+	/** Options, configuration, data and callbacks for the ag-grid table. */
+	$scope.gridOptions = {
+		columnDefs: columns,
+		enableCellTextSelection: true,
+		suppressMenuHide: true,
+		multiSortKey: 'ctrl',
+		alwaysShowVerticalScroll: true,
+		defaultColDef: {
+			filter: true,
+			sortable: true,
+			resizable: true,
+			tooltipValueGetter: defaultTooltip
+		},
+		rowData: notifications,
+		pagination: true,
+		paginationPageSize: $scope.pageSize,
+		rowBuffer: 0,
+		onColumnResized: function(params) {
+			localStorage.setItem(tableName + "_table_columns", JSON.stringify($scope.gridOptions.columnApi.getColumnState()));
+		},
+		tooltipShowDelay: 500,
+		allowContextMenuWithControlKey: true,
+		preventDefaultOnContextMenu: true,
+		onCellContextMenu: function(params) {
+			$scope.showMenu = true;
+			$scope.menuStyle.left = String(params.event.clientX) + "px";
+			$scope.menuStyle.top = String(params.event.clientY) + "px";
+			$scope.menuStyle.bottom = "unset";
+			$scope.menuStyle.right = "unset";
+			$scope.$apply();
+			const boundingRect = document.getElementById("context-menu").getBoundingClientRect();
+
+			if (boundingRect.bottom > window.innerHeight){
+				$scope.menuStyle.bottom = String(window.innerHeight - params.event.clientY) + "px";
+				$scope.menuStyle.top = "unset";
+			}
+			if (boundingRect.right > window.innerWidth) {
+				$scope.menuStyle.right = String(window.innerWidth - params.event.clientX) + "px";
+				$scope.menuStyle.left = "unset";
+			}
+			$scope.notification = params.data;
+			$scope.$apply();
+		},
+		onColumnVisible: function(params) {
+			if (params.visible){
+				return;
+			}
+			for (let column of params.columns) {
+				if (column.filterActive) {
+					const filterModel = $scope.gridOptions.api.getFilterModel();
+					if (column.colId in filterModel) {
+						delete filterModel[column.colId];
+						$scope.gridOptions.api.setFilterModel(filterModel);
+					}
+				}
+			}
+		},
+		onFirstDataRendered: function() {
+			try {
+				const filterState = JSON.parse(localStorage.getItem(tableName + "_table_filters")) || {};
+				$scope.gridOptions.api.setFilterModel(filterState);
+			} catch (e) {
+				console.error("Failure to load stored filter state:", e);
+			}
+
+			$scope.gridOptions.api.addEventListener("filterChanged", function() {
+				localStorage.setItem(tableName + "_table_filters", JSON.stringify($scope.gridOptions.api.getFilterModel()));
+			});
+		},
+		onGridReady: function() {
+			try {
+				// need to create the show/hide column checkboxes and bind to the current visibility
+				const colstates = JSON.parse(localStorage.getItem(tableName + "_table_columns"));
+				if (colstates) {
+					if (!$scope.gridOptions.columnApi.setColumnState(colstates)) {
+						console.error("Failed to load stored column state: one or more columns not found");
+					}
+				} else {
+					$scope.gridOptions.api.sizeColumnsToFit();
+				}
+			} catch (e) {
+				console.error("Failure to retrieve required column info from localStorage (key=" + tableName + "_table_columns):", e);
+			}
+
+			try {
+				const sortState = JSON.parse(localStorage.getItem(tableName + "_table_sort"));
+				$scope.gridOptions.api.setSortModel(sortState);
+			} catch (e) {
+				console.error("Failure to load stored sort state:", e);
+			}
+
+			try {
+				$scope.quickSearch = localStorage.getItem(tableName + "_quick_search");
+				$scope.gridOptions.api.setQuickFilter($scope.quickSearch);
+			} catch (e) {
+				console.error("Failure to load stored quick search:", e);
+			}
+
+			try {
+				const ps = localStorage.getItem(tableName + "_page_size");
+				if (ps && ps > 0) {
+					$scope.pageSize = Number(ps);
+					$scope.gridOptions.api.paginationSetPageSize($scope.pageSize);
+				}
+			} catch (e) {
+				console.error("Failure to load stored page size:", e);
+			}
+
+			try {
+				const page = parseInt(localStorage.getItem(tableName + "_table_page"));
+				const totalPages = $scope.gridOptions.api.paginationGetTotalPages();
+				if (page !== undefined && page > 0 && page <= totalPages-1) {
+					$scope.gridOptions.api.paginationGoToPage(page);
+				}
+			} catch (e) {
+				console.error("Failed to load stored page number:", e);
+			}
+
+			$scope.gridOptions.api.addEventListener("paginationChanged", function() {
+				localStorage.setItem(tableName + "_table_page", $scope.gridOptions.api.paginationGetCurrentPage());
+			});
+
+			$scope.gridOptions.api.addEventListener("sortChanged", function() {
+				localStorage.setItem(tableName + "_table_sort", JSON.stringify($scope.gridOptions.api.getSortModel()));
+			});
+
+			$scope.gridOptions.api.addEventListener("columnMoved", function() {
+				localStorage.setItem(tableName + "_table_columns", JSON.stringify($scope.gridOptions.columnApi.getColumnState()));
+			});
+
+			$scope.gridOptions.api.addEventListener("columnVisible", function() {
+				$scope.gridOptions.api.sizeColumnsToFit();
+				try {
+					const colStates = $scope.gridOptions.columnApi.getColumnState();
+					localStorage.setItem(tableName + "_table_columns", JSON.stringify(colStates));
+				} catch (e) {
+					console.error("Failed to store column defs to local storage:", e);
+				}
+			});
+		},
+		colResizeDefault: "shift"
+	};
+
+	/** This is used to position the context menu under the cursor. */
+	$scope.menuStyle = {
+		left: 0,
+		top: 0,
+	};
+
+	/** Toggles the visibility of a column that has the ID provided as 'col'. */
+	$scope.toggleVisibility = function(col) {
+		const visible = $scope.gridOptions.columnApi.getColumn(col).isVisible();
+		$scope.gridOptions.columnApi.setColumnVisible(col, !visible);
+	};
+
+	/** Downloads the table as a CSV */
+	$scope.exportCSV = function() {
+		const params = {
+			allColumns: true,
+			fileName: "invalidation_requests.csv",
+		};
+		$scope.gridOptions.api.exportDataAsCsv(params);
+	}
+
+	$scope.onQuickSearchChanged = function() {
+		$scope.gridOptions.api.setQuickFilter($scope.quickSearch);
+		localStorage.setItem(tableName + "_quick_search", $scope.quickSearch);
+	};
+
+	$scope.onPageSizeChanged = function() {
+		const value = Number($scope.pageSize);
+		$scope.gridOptions.api.paginationSetPageSize(value);
+		localStorage.setItem(tableName + "_page_size", value);
+	};
+
+	$scope.clearTableFilters = function() {
+		// clear the quick search
+		$scope.quickSearch = '';
+		$scope.onQuickSearchChanged();
+		// clear any column filters
+		$scope.gridOptions.api.setFilterModel(null);
+	};
+
+	$scope.selectCDNandCreateNotification = function() {
+		const params = {
+			title: 'Create Notification',
+			message: "Please select a CDN"
+		};
+		const modalInstance = $uibModal.open({
+			templateUrl: 'common/modules/dialog/select/dialog.select.tpl.html',
+			controller: 'DialogSelectController',
+			size: 'md',
+			resolve: {
+				params: function () {
+					return params;
+				},
+				collection: function(cdnService) {
+					return cdnService.getCDNs();
+				}
+			}
+		});
+		modalInstance.result.then(function(cdn) {
+			$scope.createNotification(cdn);
+		}, function () {
+			// do nothing
+		});
+	};
+
+	$scope.createNotification = function(cdn) {
+		const params = {
+			title: 'Create ' + cdn.name + ' Notification',
+			message: 'What is the content of your notification for the ' + cdn.name + ' CDN?'
+		};
+		const modalInstance = $uibModal.open({
+			templateUrl: 'common/modules/dialog/input/dialog.input.tpl.html',
+			controller: 'DialogInputController',
+			size: 'md',
+			resolve: {
+				params: function () {
+					return params;
+				}
+			}
+		});
+		modalInstance.result.then(function(notification) {
+			cdnService.createNotification(cdn, notification).
+			then(
+				function() {
+					$state.reload();
+				}
+			);
+		}, function () {
+			// do nothing
+		});
+	};
+
+	$scope.confirmDeleteNotification = function(notification, event) {
+		event.stopPropagation();
+		const params = {
+			title: 'Delete Notification',
+			message: 'Are you sure you want to delete the notification for the ' + notification.cdn + ' CDN? This will remove the notification from the view of all users.'
+		};
+		const modalInstance = $uibModal.open({
+			templateUrl: 'common/modules/dialog/confirm/dialog.confirm.tpl.html',
+			controller: 'DialogConfirmController',
+			size: 'md',
+			resolve: {
+				params: function () {
+					return params;
+				}
+			}
+		});
+		modalInstance.result.then(function() {
+			cdnService.deleteNotification({ id: notification.id }).
+				then(
+					function() {
+						$state.reload();
+					}
+				);
+		}, function () {
+			// do nothing
+		});
+	};
+
+	/**** Initialization code, including loading user columns from localstorage ****/
+	angular.element(document).ready(function () {
+		// clicks outside the context menu will hide it
+		$document.bind("click", function(e) {
+			$scope.showMenu = false;
+			e.stopPropagation();
+			$scope.$apply();
+		});
+	});
+
+};
+
+TableNotificationsController.$inject = ['tableName', 'notifications', 'filter', '$scope', '$state', '$uibModal', '$document', 'dateUtils', 'cdnService'];
+module.exports = TableNotificationsController;
diff --git a/traffic_portal/app/src/common/modules/table/notifications/index.js b/traffic_portal/app/src/common/modules/table/notifications/index.js
new file mode 100644
index 0000000..d9291a8
--- /dev/null
+++ b/traffic_portal/app/src/common/modules/table/notifications/index.js
@@ -0,0 +1,21 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+module.exports = angular.module('trafficPortal.table.notifications', [])
+	.controller('TableNotificationsController', require('./TableNotificationsController'));
diff --git a/traffic_portal/app/src/common/modules/table/notifications/table.notifications.tpl.html b/traffic_portal/app/src/common/modules/table/notifications/table.notifications.tpl.html
new file mode 100644
index 0000000..67cfe47
--- /dev/null
+++ b/traffic_portal/app/src/common/modules/table/notifications/table.notifications.tpl.html
@@ -0,0 +1,74 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+<div class="x_panel">
+    <div class="x_title">
+        <ol class="breadcrumb pull-left">
+            <li class="active">Notifications</li>
+        </ol>
+        <div class="pull-right">
+            <div class="form-inline" role="search">
+                <input id="quickSearch" name="quickSearch" type="search" class="form-control text-input" placeholder="Quick search..." ng-model="quickSearch" ng-change="onQuickSearchChanged()" aria-label="Search"/>
+                <div class="input-group text-input">
+                    <span class="input-group-addon">
+                        <label for="pageSize">Page size</label>
+                    </span>
+                    <input id="pageSize" name="pageSize" type="number" min="1" class="form-control" placeholder="100" ng-model="pageSize" ng-change="onPageSizeChanged()" aria-label="Page Size"/>
+                </div>
+                <div id="toggleColumns" class="btn-group" role="group" title="Select Table Columns" uib-dropdown is-open="columnSettings.isopen">
+                    <button type="button" class="btn btn-default dropdown-toggle" uib-dropdown-toggle aria-haspopup="true" aria-expanded="false">
+                        <i class="fa fa-columns"></i>&nbsp;
+                        <span class="caret"></span>
+                    </button>
+                    <menu ng-click="$event.stopPropagation()" class="column-settings dropdown-menu-right dropdown-menu" uib-dropdown-menu>
+                        <li role="menuitem" ng-repeat="c in gridOptions.columnApi.getAllColumns() | orderBy:'colDef.headerName'">
+                            <div class="checkbox">
+                                <label><input type="checkbox" ng-checked="c.isVisible()" ng-click="toggleVisibility(c.colId)">{{::c.colDef.headerName}}</label>
+                            </div>
+                        </li>
+                    </menu>
+                </div>
+                <div class="btn-group" role="group" uib-dropdown is-open="more.isopen">
+                    <button name="moreBtn" type="button" class="btn btn-default dropdown-toggle" uib-dropdown-toggle aria-haspopup="true" aria-expanded="false">
+                        More&nbsp;
+                        <span class="caret"></span>
+                    </button>
+                    <ul class="dropdown-menu-right dropdown-menu" uib-dropdown-menu>
+                        <li role="menuitem"><button class="menu-item-button" type="button" ng-click="selectCDNandCreateNotification()">Create Notification</button></li>
+                        <li class="divider"></li>
+                        <li role="menuitem"><button class="menu-item-button" type="button" ng-click="clearTableFilters()">Clear Table Filters</button></li>
+                        <li role="menuitem"><button class="menu-item-button" type="button" ng-click="exportCSV()">Export CSV</button></li>
+                    </ul>
+                </div>
+            </div>
+        </div>
+        <div class="clearfix"></div>
+    </div>
+    <div class="x_content">
+        <div style="height: 740px;" ag-grid="gridOptions" class="ag-theme-alpine"></div>
+    </div>
+</div>
+
+<menu id="context-menu" class="dropdown-menu" ng-style="menuStyle" type="contextmenu" ng-show="showMenu">
+    <ul>
+        <li role="menuitem">
+            <button type="button" ng-click="confirmDeleteNotification(notification, $event)">Delete Notification</button>
+        </li>
+    </ul>
+</menu>
diff --git a/traffic_portal/app/src/modules/private/cdns/notifications/index.js b/traffic_portal/app/src/modules/private/cdns/notifications/index.js
new file mode 100644
index 0000000..3e04cb2
--- /dev/null
+++ b/traffic_portal/app/src/modules/private/cdns/notifications/index.js
@@ -0,0 +1,51 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+module.exports = angular.module('trafficPortal.private.cdns.notifications', [])
+	.config(function($stateProvider, $urlRouterProvider) {
+		$stateProvider
+			.state('trafficPortal.private.cdns.notifications', {
+				url: '/{cdnId}/notifications',
+				views: {
+					cdnsContent: {
+						templateUrl: 'common/modules/table/cdnNotifications/table.cdnNotifications.tpl.html',
+						controller: 'TableCDNNotificationsController',
+						resolve: {
+							cdn: function($stateParams, cdnService) {
+								return cdnService.getCDN($stateParams.cdnId);
+							},
+							notifications: function(cdn, cdnService) {
+								return cdnService.getNotifications({ cdn: cdn.name, orderby: 'lastUpdated' });
+							},
+							filter: function(cdn) {
+								return {
+									cdn: {
+										filterType: "text",
+										type: "equals",
+										filter: cdn.name
+									}
+								}
+							}
+						}
+					}
+				}
+			})
+		;
+		$urlRouterProvider.otherwise('/');
+	});
diff --git a/traffic_portal/app/src/modules/private/notifications/index.js b/traffic_portal/app/src/modules/private/notifications/index.js
new file mode 100644
index 0000000..e029b2b
--- /dev/null
+++ b/traffic_portal/app/src/modules/private/notifications/index.js
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+module.exports = angular.module('trafficPortal.private.notifications', [])
+	.config(function($stateProvider, $urlRouterProvider) {
+		$stateProvider
+			.state('trafficPortal.private.notifications', {
+				url: 'notifications',
+				abstract: true,
+				views: {
+					privateContent: {
+						templateUrl: 'modules/private/notifications/notifications.tpl.html'
+					}
+				}
+			})
+		;
+		$urlRouterProvider.otherwise('/');
+	});
diff --git a/traffic_portal/app/src/modules/private/notifications/list/index.js b/traffic_portal/app/src/modules/private/notifications/list/index.js
new file mode 100644
index 0000000..9700eda
--- /dev/null
+++ b/traffic_portal/app/src/modules/private/notifications/list/index.js
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+module.exports = angular.module('trafficPortal.private.notifications.list', [])
+	.config(function($stateProvider, $urlRouterProvider) {
+		$stateProvider
+			.state('trafficPortal.private.notifications.list', {
+				url: '',
+				views: {
+					notificationsContent: {
+						templateUrl: 'common/modules/table/notifications/table.notifications.tpl.html',
+						controller: 'TableNotificationsController',
+						resolve: {
+							tableName: function() {
+								return 'notifications';
+							},
+							notifications: function(cdnService) {
+								return cdnService.getNotifications();
+							},
+							filter: function() {
+								return null;
+							}
+						}
+					}
+				}
+			})
+		;
+		$urlRouterProvider.otherwise('/');
+	});
diff --git a/traffic_portal/app/src/common/modules/notifications/notifications.tpl.html b/traffic_portal/app/src/modules/private/notifications/notifications.tpl.html
similarity index 78%
copy from traffic_portal/app/src/common/modules/notifications/notifications.tpl.html
copy to traffic_portal/app/src/modules/private/notifications/notifications.tpl.html
index 87ffaad..e79d4fc 100644
--- a/traffic_portal/app/src/common/modules/notifications/notifications.tpl.html
+++ b/traffic_portal/app/src/modules/private/notifications/notifications.tpl.html
@@ -17,8 +17,6 @@ specific language governing permissions and limitations
 under the License.
 -->
 
-<div id="notificationsContainer">
-    <div class="alert alert-info" ng-repeat="n in notifications">
-        <div ng-bind-html="n.cdn + ': ' + n.notification + ' (' + n.user + ')' | linky:'_blank'"></div>
-    </div>
+<div id="cdnNotificationsContainer">
+    <div ui-view="notificationsContent"></div>
 </div>