diff --git a/manager/domain/enums.go b/manager/domain/enums.go
index 9bcd004..34de0d9 100644
--- a/manager/domain/enums.go
+++ b/manager/domain/enums.go
@@ -14,6 +14,7 @@ const (
PermissionRead PermissionKey = "permission.read"
ScheduleStrategyCreate PermissionKey = "schedule_strategy.create"
ScheduleStrategyRead PermissionKey = "schedule_strategy.read"
+ ScheduleStrategyUpdate PermissionKey = "schedule_strategy.update"
ScheduleStrategyDelete PermissionKey = "schedule_strategy.delete"
ScheduleIntentRead PermissionKey = "schedule_intent.read"
ScheduleIntentDelete PermissionKey = "schedule_intent.delete"
diff --git a/manager/domain/interface.go b/manager/domain/interface.go
index f687276..af8250c 100644
--- a/manager/domain/interface.go
+++ b/manager/domain/interface.go
@@ -67,6 +67,7 @@ type Repository interface {
BatchUpdateIntentsState(ctx context.Context, intentIDs []bson.ObjectID, newState IntentState) error
QueryStrategies(ctx context.Context, opt *QueryStrategyOptions) error
QueryIntents(ctx context.Context, opt *QueryIntentOptions) error
+ UpdateStrategy(ctx context.Context, strategyID bson.ObjectID, update bson.M) error
DeleteStrategy(ctx context.Context, strategyID bson.ObjectID) error
DeleteIntents(ctx context.Context, intentIDs []bson.ObjectID) error
DeleteIntentsByStrategyID(ctx context.Context, strategyID bson.ObjectID) error
@@ -91,6 +92,7 @@ type Service interface {
CreateScheduleStrategy(ctx context.Context, operator *Claims, strategy *ScheduleStrategy) error
ListScheduleStrategies(ctx context.Context, filterOpts *QueryStrategyOptions) error
ListScheduleIntents(ctx context.Context, filterOpts *QueryIntentOptions) error
+ UpdateScheduleStrategy(ctx context.Context, operator *Claims, strategyID string, strategy *ScheduleStrategy) error
DeleteScheduleStrategy(ctx context.Context, operator *Claims, strategyID string) error
DeleteScheduleIntents(ctx context.Context, operator *Claims, intentIDs []string) error
GetPodPIDMapping(ctx context.Context, nodeID string) (*PodPIDMappingResponse, error)
diff --git a/manager/domain/mock_domain.go b/manager/domain/mock_domain.go
index 6feea00..258cf2a 100644
--- a/manager/domain/mock_domain.go
+++ b/manager/domain/mock_domain.go
@@ -443,6 +443,69 @@ func (_c *MockRepository_DeleteIntentsByStrategyID_Call) RunAndReturn(run func(c
return _c
}
+// UpdateStrategy provides a mock function for the type MockRepository
+func (_mock *MockRepository) UpdateStrategy(ctx context.Context, strategyID bson.ObjectID, update bson.M) error {
+ ret := _mock.Called(ctx, strategyID, update)
+
+ if len(ret) == 0 {
+ panic("no return value specified for UpdateStrategy")
+ }
+
+ var r0 error
+ if returnFunc, ok := ret.Get(0).(func(context.Context, bson.ObjectID, bson.M) error); ok {
+ r0 = returnFunc(ctx, strategyID, update)
+ } else {
+ r0 = ret.Error(0)
+ }
+ return r0
+}
+
+// MockRepository_UpdateStrategy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateStrategy'
+type MockRepository_UpdateStrategy_Call struct {
+ *mock.Call
+}
+
+// UpdateStrategy is a helper method to define mock.On call
+// - ctx context.Context
+// - strategyID bson.ObjectID
+// - update bson.M
+func (_e *MockRepository_Expecter) UpdateStrategy(ctx interface{}, strategyID interface{}, update interface{}) *MockRepository_UpdateStrategy_Call {
+ return &MockRepository_UpdateStrategy_Call{Call: _e.mock.On("UpdateStrategy", ctx, strategyID, update)}
+}
+
+func (_c *MockRepository_UpdateStrategy_Call) Run(run func(ctx context.Context, strategyID bson.ObjectID, update bson.M)) *MockRepository_UpdateStrategy_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ var arg0 context.Context
+ if args[0] != nil {
+ arg0 = args[0].(context.Context)
+ }
+ var arg1 bson.ObjectID
+ if args[1] != nil {
+ arg1 = args[1].(bson.ObjectID)
+ }
+ var arg2 bson.M
+ if args[2] != nil {
+ arg2 = args[2].(bson.M)
+ }
+ run(
+ arg0,
+ arg1,
+ arg2,
+ )
+ })
+ return _c
+}
+
+func (_c *MockRepository_UpdateStrategy_Call) Return(err error) *MockRepository_UpdateStrategy_Call {
+ _c.Call.Return(err)
+ return _c
+}
+
+func (_c *MockRepository_UpdateStrategy_Call) RunAndReturn(run func(ctx context.Context, strategyID bson.ObjectID, update bson.M) error) *MockRepository_UpdateStrategy_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
// DeleteStrategy provides a mock function for the type MockRepository
func (_mock *MockRepository) DeleteStrategy(ctx context.Context, strategyID bson.ObjectID) error {
ret := _mock.Called(ctx, strategyID)
@@ -1487,6 +1550,75 @@ func (_c *MockService_CreateScheduleStrategy_Call) RunAndReturn(run func(ctx con
return _c
}
+// UpdateScheduleStrategy provides a mock function for the type MockService
+func (_mock *MockService) UpdateScheduleStrategy(ctx context.Context, operator *Claims, strategyID string, strategy *ScheduleStrategy) error {
+ ret := _mock.Called(ctx, operator, strategyID, strategy)
+
+ if len(ret) == 0 {
+ panic("no return value specified for UpdateScheduleStrategy")
+ }
+
+ var r0 error
+ if returnFunc, ok := ret.Get(0).(func(context.Context, *Claims, string, *ScheduleStrategy) error); ok {
+ r0 = returnFunc(ctx, operator, strategyID, strategy)
+ } else {
+ r0 = ret.Error(0)
+ }
+ return r0
+}
+
+// MockService_UpdateScheduleStrategy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateScheduleStrategy'
+type MockService_UpdateScheduleStrategy_Call struct {
+ *mock.Call
+}
+
+// UpdateScheduleStrategy is a helper method to define mock.On call
+// - ctx context.Context
+// - operator *Claims
+// - strategyID string
+// - strategy *ScheduleStrategy
+func (_e *MockService_Expecter) UpdateScheduleStrategy(ctx interface{}, operator interface{}, strategyID interface{}, strategy interface{}) *MockService_UpdateScheduleStrategy_Call {
+ return &MockService_UpdateScheduleStrategy_Call{Call: _e.mock.On("UpdateScheduleStrategy", ctx, operator, strategyID, strategy)}
+}
+
+func (_c *MockService_UpdateScheduleStrategy_Call) Run(run func(ctx context.Context, operator *Claims, strategyID string, strategy *ScheduleStrategy)) *MockService_UpdateScheduleStrategy_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ var arg0 context.Context
+ if args[0] != nil {
+ arg0 = args[0].(context.Context)
+ }
+ var arg1 *Claims
+ if args[1] != nil {
+ arg1 = args[1].(*Claims)
+ }
+ var arg2 string
+ if args[2] != nil {
+ arg2 = args[2].(string)
+ }
+ var arg3 *ScheduleStrategy
+ if args[3] != nil {
+ arg3 = args[3].(*ScheduleStrategy)
+ }
+ run(
+ arg0,
+ arg1,
+ arg2,
+ arg3,
+ )
+ })
+ return _c
+}
+
+func (_c *MockService_UpdateScheduleStrategy_Call) Return(err error) *MockService_UpdateScheduleStrategy_Call {
+ _c.Call.Return(err)
+ return _c
+}
+
+func (_c *MockService_UpdateScheduleStrategy_Call) RunAndReturn(run func(ctx context.Context, operator *Claims, strategyID string, strategy *ScheduleStrategy) error) *MockService_UpdateScheduleStrategy_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
// DeleteRole provides a mock function for the type MockService
func (_mock *MockService) DeleteRole(ctx context.Context, operator *Claims, roleID string) error {
ret := _mock.Called(ctx, operator, roleID)
diff --git a/manager/migration/005_add_schedule_strategy_update_permission.down.json b/manager/migration/005_add_schedule_strategy_update_permission.down.json
new file mode 100644
index 0000000..e7c40c3
--- /dev/null
+++ b/manager/migration/005_add_schedule_strategy_update_permission.down.json
@@ -0,0 +1,24 @@
+[
+ {
+ "update": "roles",
+ "updates": [
+ {
+ "q": { "name": "admin" },
+ "u": {
+ "$pull": {
+ "policies": { "permissionKey": "schedule_strategy.update" }
+ }
+ }
+ }
+ ]
+ },
+ {
+ "delete": "permissions",
+ "deletes": [
+ {
+ "q": { "key": "schedule_strategy.update" },
+ "limit": 1
+ }
+ ]
+ }
+]
diff --git a/manager/migration/005_add_schedule_strategy_update_permission.up.json b/manager/migration/005_add_schedule_strategy_update_permission.up.json
new file mode 100644
index 0000000..783e4e5
--- /dev/null
+++ b/manager/migration/005_add_schedule_strategy_update_permission.up.json
@@ -0,0 +1,26 @@
+[
+ {
+ "insert": "permissions",
+ "documents": [
+ {
+ "key": "schedule_strategy.update",
+ "resource": "schedule_strategy",
+ "action": "update",
+ "description": "Update schedule strategies"
+ }
+ ]
+ },
+ {
+ "update": "roles",
+ "updates": [
+ {
+ "q": { "name": "admin" },
+ "u": {
+ "$push": {
+ "policies": { "permissionKey": "schedule_strategy.update", "self": false }
+ }
+ }
+ }
+ ]
+ }
+]
diff --git a/manager/repository/strategy_repo.go b/manager/repository/strategy_repo.go
index 74e4fb6..d0fea9b 100644
--- a/manager/repository/strategy_repo.go
+++ b/manager/repository/strategy_repo.go
@@ -158,6 +158,14 @@ func (r *repo) DeleteStrategy(ctx context.Context, strategyID bson.ObjectID) err
return err
}
+func (r *repo) UpdateStrategy(ctx context.Context, strategyID bson.ObjectID, update bson.M) error {
+ if update == nil {
+ return errors.New("nil update")
+ }
+ _, err := r.db.Collection(scheduleStrategyCollection).UpdateOne(ctx, bson.M{"_id": strategyID}, update)
+ return err
+}
+
func (r *repo) DeleteIntents(ctx context.Context, intentIDs []bson.ObjectID) error {
if len(intentIDs) == 0 {
return nil
diff --git a/manager/rest/routes.go b/manager/rest/routes.go
index 77a2dce..df9759e 100644
--- a/manager/rest/routes.go
+++ b/manager/rest/routes.go
@@ -40,6 +40,7 @@ func (h *Handler) SetupRoutes(engine *echo.Echo) {
// strategy routes
apiV1.POST("/strategies", h.echoHandler(h.CreateScheduleStrategy), echo.WrapMiddleware(h.GetAuthMiddleware(domain.ScheduleStrategyCreate)))
+ apiV1.PUT("/strategies", h.echoHandler(h.UpdateScheduleStrategy), echo.WrapMiddleware(h.GetAuthMiddleware(domain.ScheduleStrategyUpdate)))
apiV1.GET("/strategies/self", h.echoHandler(h.ListSelfScheduleStrategies), echo.WrapMiddleware(h.GetAuthMiddleware(domain.ScheduleStrategyRead)))
apiV1.DELETE("/strategies", h.echoHandler(h.DeleteScheduleStrategy), echo.WrapMiddleware(h.GetAuthMiddleware(domain.ScheduleStrategyDelete)))
apiV1.GET("/intents/self", h.echoHandler(h.ListSelfScheduleIntents), echo.WrapMiddleware(h.GetAuthMiddleware(domain.ScheduleIntentRead)))
diff --git a/manager/rest/strategy_hdl.go b/manager/rest/strategy_hdl.go
index 4519fd4..ea84172 100644
--- a/manager/rest/strategy_hdl.go
+++ b/manager/rest/strategy_hdl.go
@@ -21,6 +21,16 @@ type CreateScheduleStrategyRequest struct {
ExecutionTime int64 `json:"executionTime,omitempty"`
}
+type UpdateScheduleStrategyRequest struct {
+ StrategyID string `json:"strategyId"`
+ StrategyNamespace string `json:"strategyNamespace,omitempty"`
+ LabelSelectors []LabelSelector `json:"labelSelectors,omitempty"`
+ K8sNamespace []string `json:"k8sNamespace,omitempty"`
+ CommandRegex string `json:"commandRegex,omitempty"`
+ Priority int `json:"priority,omitempty"`
+ ExecutionTime int64 `json:"executionTime,omitempty"`
+}
+
// CreateScheduleStrategy godoc
// @Summary Create schedule strategy
// @Description Create a new schedule strategy.
@@ -75,6 +85,63 @@ func (h *Handler) CreateScheduleStrategy(w http.ResponseWriter, r *http.Request)
h.JSONResponse(ctx, w, http.StatusOK, response)
}
+// UpdateScheduleStrategy godoc
+// @Summary Update schedule strategy
+// @Description Update an existing schedule strategy.
+// @Tags Strategies
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Param request body UpdateScheduleStrategyRequest true "Schedule strategy payload"
+// @Success 200 {object} SuccessResponse[EmptyResponse]
+// @Failure 400 {object} ErrorResponse
+// @Failure 401 {object} ErrorResponse
+// @Failure 403 {object} ErrorResponse
+// @Failure 404 {object} ErrorResponse
+// @Failure 500 {object} ErrorResponse
+// @Router /api/v1/strategies [put]
+func (h *Handler) UpdateScheduleStrategy(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ var req UpdateScheduleStrategyRequest
+ if err := h.JSONBind(r, &req); err != nil {
+ h.ErrorResponse(ctx, w, http.StatusBadRequest, "Invalid request body", err)
+ return
+ }
+ if req.StrategyID == "" {
+ h.ErrorResponse(ctx, w, http.StatusBadRequest, "Strategy ID is required", nil)
+ return
+ }
+
+ strategy := &domain.ScheduleStrategy{
+ StrategyNamespace: req.StrategyNamespace,
+ LabelSelectors: make([]domain.LabelSelector, len(req.LabelSelectors)),
+ K8sNamespace: req.K8sNamespace,
+ CommandRegex: req.CommandRegex,
+ Priority: req.Priority,
+ ExecutionTime: req.ExecutionTime,
+ }
+ for i, ls := range req.LabelSelectors {
+ strategy.LabelSelectors[i] = domain.LabelSelector{
+ Key: ls.Key,
+ Value: ls.Value,
+ }
+ }
+
+ claims, ok := h.GetClaimsFromContext(ctx)
+ if !ok {
+ h.ErrorResponse(ctx, w, http.StatusUnauthorized, "Unauthorized", nil)
+ return
+ }
+
+ if err := h.Svc.UpdateScheduleStrategy(ctx, &claims, req.StrategyID, strategy); err != nil {
+ h.HandleError(ctx, w, err)
+ return
+ }
+
+ response := NewSuccessResponse[string](nil)
+ h.JSONResponse(ctx, w, http.StatusOK, response)
+}
+
type ListSchedulerStrategiesResponse struct {
Strategies []*ScheduleStrategy `json:"strategies"`
}
diff --git a/manager/service/strategy_svc.go b/manager/service/strategy_svc.go
index 09b7274..9c2498e 100644
--- a/manager/service/strategy_svc.go
+++ b/manager/service/strategy_svc.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
+ "time"
"github.com/Gthulhu/api/manager/domain"
"github.com/Gthulhu/api/manager/errs"
@@ -106,6 +107,182 @@ func (svc *Service) ListScheduleIntents(ctx context.Context, filterOpts *domain.
return svc.Repo.QueryIntents(ctx, filterOpts)
}
+func (svc *Service) UpdateScheduleStrategy(ctx context.Context, operator *domain.Claims, strategyID string, strategy *domain.ScheduleStrategy) error {
+ strategyObjID, err := bson.ObjectIDFromHex(strategyID)
+ if err != nil {
+ return errors.WithMessagef(err, "invalid strategy ID %s", strategyID)
+ }
+
+ operatorID, err := operator.GetBsonObjectUID()
+ if err != nil {
+ return errors.WithMessagef(err, "invalid operator ID %s", operator.UID)
+ }
+
+ // Validate ownership and load existing strategy
+ queryOpt := &domain.QueryStrategyOptions{
+ IDs: []bson.ObjectID{strategyObjID},
+ CreatorIDs: []bson.ObjectID{operatorID},
+ }
+ if err := svc.Repo.QueryStrategies(ctx, queryOpt); err != nil {
+ return err
+ }
+ if len(queryOpt.Result) == 0 {
+ return errs.NewHTTPStatusError(http.StatusNotFound, "strategy not found or you don't have permission to update it", nil)
+ }
+ currentStrategy := queryOpt.Result[0]
+
+ // Query pods based on new strategy criteria before making changes
+ queryPodsOpt := &domain.QueryPodsOptions{
+ K8SNamespace: strategy.K8sNamespace,
+ LabelSelectors: strategy.LabelSelectors,
+ CommandRegex: strategy.CommandRegex,
+ }
+ pods, err := svc.K8SAdapter.QueryPods(ctx, queryPodsOpt)
+ if err != nil {
+ return err
+ }
+ if len(pods) == 0 {
+ return errs.NewHTTPStatusError(http.StatusNotFound, "no pods match the strategy criteria", fmt.Errorf("no pods found for the given namespaces and label selectors, opts:%+v", queryPodsOpt))
+ }
+
+ // Load existing intents for DM cleanup
+ oldIntentQuery := &domain.QueryIntentOptions{
+ StrategyIDs: []bson.ObjectID{strategyObjID},
+ }
+ if err := svc.Repo.QueryIntents(ctx, oldIntentQuery); err != nil {
+ return fmt.Errorf("query intents for strategy: %w", err)
+ }
+
+ // Update strategy document
+ now := time.Now().UnixMilli()
+ update := bson.M{
+ "$set": bson.M{
+ "strategyNamespace": strategy.StrategyNamespace,
+ "labelSelectors": strategy.LabelSelectors,
+ "k8sNamespace": strategy.K8sNamespace,
+ "commandRegex": strategy.CommandRegex,
+ "priority": strategy.Priority,
+ "executionTime": strategy.ExecutionTime,
+ "updaterID": operatorID,
+ "updatedTime": now,
+ },
+ }
+ if err := svc.Repo.UpdateStrategy(ctx, strategyObjID, update); err != nil {
+ return fmt.Errorf("update strategy: %w", err)
+ }
+
+ // Replace intents for the strategy
+ if err := svc.Repo.DeleteIntentsByStrategyID(ctx, strategyObjID); err != nil {
+ return fmt.Errorf("delete intents by strategy ID: %w", err)
+ }
+
+ strategy.ID = strategyObjID
+ strategy.CreatedTime = currentStrategy.CreatedTime
+ strategy.CreatorID = currentStrategy.CreatorID
+ strategy.UpdaterID = operatorID
+ strategy.UpdatedTime = now
+
+ intents := make([]*domain.ScheduleIntent, 0, len(pods))
+ nodeIDsMap := make(map[string]struct{})
+ nodeIDs := make([]string, 0)
+ for _, pod := range pods {
+ intent := domain.NewScheduleIntent(strategy, pod)
+ intents = append(intents, &intent)
+ if _, exists := nodeIDsMap[pod.NodeID]; !exists {
+ nodeIDsMap[pod.NodeID] = struct{}{}
+ nodeIDs = append(nodeIDs, pod.NodeID)
+ }
+ }
+
+ if err := svc.Repo.InsertIntents(ctx, intents); err != nil {
+ return fmt.Errorf("insert intents into repository: %w", err)
+ }
+
+ // Notify decision makers to remove old intents
+ if len(oldIntentQuery.Result) > 0 {
+ oldNodeIDsMap := make(map[string]struct{})
+ oldPodIDsMap := make(map[string]struct{})
+ for _, intent := range oldIntentQuery.Result {
+ oldNodeIDsMap[intent.NodeID] = struct{}{}
+ oldPodIDsMap[intent.PodID] = struct{}{}
+ }
+ oldNodeIDs := make([]string, 0, len(oldNodeIDsMap))
+ for nodeID := range oldNodeIDsMap {
+ oldNodeIDs = append(oldNodeIDs, nodeID)
+ }
+ oldPodIDs := make([]string, 0, len(oldPodIDsMap))
+ for podID := range oldPodIDsMap {
+ oldPodIDs = append(oldPodIDs, podID)
+ }
+
+ dmLabel := domain.LabelSelector{
+ Key: "app",
+ Value: "decisionmaker",
+ }
+ dmQueryOpt := &domain.QueryDecisionMakerPodsOptions{
+ DecisionMakerLabel: dmLabel,
+ NodeIDs: oldNodeIDs,
+ }
+ dmPods, err := svc.K8SAdapter.QueryDecisionMakerPods(ctx, dmQueryOpt)
+ if err != nil {
+ logger.Logger(ctx).Warn().Err(err).Msg("failed to query decision maker pods for update deletion notification")
+ } else if len(oldPodIDs) > 0 {
+ deleteReq := &domain.DeleteIntentsRequest{PodIDs: oldPodIDs}
+ for _, dmPod := range dmPods {
+ if err := svc.DMAdapter.DeleteSchedulingIntents(ctx, dmPod, deleteReq); err != nil {
+ logger.Logger(ctx).Warn().Err(err).Msgf("failed to notify decision maker %s to delete intents", dmPod.NodeID)
+ }
+ }
+ }
+ }
+
+ // Send new intents to decision makers
+ dmLabel := domain.LabelSelector{
+ Key: "app",
+ Value: "decisionmaker",
+ }
+ dmQueryOpt := &domain.QueryDecisionMakerPodsOptions{
+ DecisionMakerLabel: dmLabel,
+ NodeIDs: nodeIDs,
+ }
+ dms, err := svc.K8SAdapter.QueryDecisionMakerPods(ctx, dmQueryOpt)
+ if err != nil {
+ return err
+ }
+ if len(dms) == 0 {
+ logger.Logger(ctx).Warn().Msgf("no decision maker pods found for scheduling intents, opts:%+v", dmQueryOpt)
+ return nil
+ }
+
+ nodeIDIntentsMap := make(map[string][]*domain.ScheduleIntent)
+ nodeIDIntentIDsMap := make(map[string][]bson.ObjectID)
+ nodeIDDMap := make(map[string]*domain.DecisionMakerPod)
+ for _, dmPod := range dms {
+ for _, intent := range intents {
+ if intent.NodeID == dmPod.NodeID {
+ nodeIDIntentIDsMap[dmPod.Host] = append(nodeIDIntentIDsMap[dmPod.Host], intent.ID)
+ nodeIDIntentsMap[dmPod.Host] = append(nodeIDIntentsMap[dmPod.Host], intent)
+ nodeIDDMap[dmPod.Host] = dmPod
+ }
+ }
+ }
+ for host, intents := range nodeIDIntentsMap {
+ dmPod := nodeIDDMap[host]
+ err = svc.DMAdapter.SendSchedulingIntent(ctx, dmPod, intents)
+ if err != nil {
+ return fmt.Errorf("send scheduling intents to decision maker %s: %w", host, err)
+ }
+ err = svc.Repo.BatchUpdateIntentsState(ctx, nodeIDIntentIDsMap[host], domain.IntentStateSent)
+ if err != nil {
+ return fmt.Errorf("update intents state: %w", err)
+ }
+ logger.Logger(ctx).Info().Msgf("sent %d scheduling intents to decision maker %s", len(intents), host)
+ }
+
+ logger.Logger(ctx).Info().Msgf("updated strategy %s and regenerated intents", strategyID)
+ return nil
+}
+
func (svc *Service) DeleteScheduleStrategy(ctx context.Context, operator *domain.Claims, strategyID string) error {
strategyObjID, err := bson.ObjectIDFromHex(strategyID)
if err != nil {
diff --git a/web/README.md b/web/README.md
index 8e8b10e..568cd40 100644
--- a/web/README.md
+++ b/web/README.md
@@ -21,8 +21,6 @@ react-app/
│ │ ├── modals/
│ │ │ ├── LoginModal.jsx
│ │ │ ├── ConfigModal.jsx
-│ │ │ ├── DeleteIntentsModal.jsx
-│ │ │ └── DeleteStrategyModal.jsx
│ │ ├── Header.jsx
│ │ ├── Footer.jsx
│ │ ├── Dashboard.jsx
diff --git a/web/src/App.jsx b/web/src/App.jsx
index 05cc506..674ca9a 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -5,8 +5,6 @@ import Footer from './components/Footer';
import Dashboard from './components/Dashboard';
import LoginModal from './components/modals/LoginModal';
import ConfigModal from './components/modals/ConfigModal';
-import DeleteIntentsModal from './components/modals/DeleteIntentsModal';
-import DeleteStrategyModal from './components/modals/DeleteStrategyModal';
import ToastContainer from './components/ToastContainer';
function App() {
@@ -24,8 +22,6 @@ function App() {
{/* Modals */}
-
-
{/* Toast Container */}
diff --git a/web/src/components/Header.jsx b/web/src/components/Header.jsx
index 0f17ac9..66e7463 100644
--- a/web/src/components/Header.jsx
+++ b/web/src/components/Header.jsx
@@ -28,7 +28,7 @@ export default function Header() {
GTHULHU
- eBPF Scheduler Control Interface
+ Next generation scheduling platform
diff --git a/web/src/components/ToastContainer.jsx b/web/src/components/ToastContainer.jsx
index 96d9857..6857914 100644
--- a/web/src/components/ToastContainer.jsx
+++ b/web/src/components/ToastContainer.jsx
@@ -1,8 +1,10 @@
-import React from 'react';
+import React, { useMemo, useState } from 'react';
+import { Bell, Trash2, X } from 'lucide-react';
import { useApp } from '../context/AppContext';
export default function ToastContainer() {
- const { toasts, removeToast } = useApp();
+ const { toasts, removeToast, clearToasts } = useApp();
+ const [isOpen, setIsOpen] = useState(true);
const icons = {
success: '✓',
@@ -11,15 +13,77 @@ export default function ToastContainer() {
warning: '⚠'
};
+ const unreadCount = toasts.length;
+ const emptyMessage = useMemo(() => {
+ return 'No notifications yet.';
+ }, []);
+
return (
-
- {toasts.map(toast => (
-
-
{icons[toast.type] || 'ℹ'}
-
{toast.message}
-
+
+
+
+ {isOpen && (
+
+
+
Notifications
+
+
+
+
+
+
+ {toasts.length === 0 && (
+
{emptyMessage}
+ )}
+
+ {toasts.length > 0 && (
+
+ {toasts.map(toast => (
+
+
{icons[toast.type] || 'ℹ'}
+
+
{toast.message}
+
+ {toast.timestamp ? new Date(toast.timestamp).toLocaleTimeString() : ''}
+
+
+
+
+ ))}
+
+ )}
- ))}
+ )}
);
}
diff --git a/web/src/components/cards/HealthCard.jsx b/web/src/components/cards/HealthCard.jsx
index ca4238c..5e04c1b 100644
--- a/web/src/components/cards/HealthCard.jsx
+++ b/web/src/components/cards/HealthCard.jsx
@@ -8,7 +8,7 @@ export default function HealthCard() {
const [healthClass, setHealthClass] = useState('');
const [healthDetails, setHealthDetails] = useState('Awaiting health check...');
const [detailsClass, setDetailsClass] = useState('');
- const [autoRefresh, setAutoRefresh] = useState(false);
+ const [autoRefresh, setAutoRefresh] = useState(true);
const intervalRef = useRef(null);
const checkHealth = useCallback(async () => {
diff --git a/web/src/components/cards/IntentsCard.jsx b/web/src/components/cards/IntentsCard.jsx
index 4672e1a..594089e 100644
--- a/web/src/components/cards/IntentsCard.jsx
+++ b/web/src/components/cards/IntentsCard.jsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useApp } from '../../context/AppContext';
-import { ClipboardList, RefreshCw, Trash2, Lock, Loader2, XCircle, Inbox, ChevronDown, ChevronRight, Server, HelpCircle } from 'lucide-react';
+import { ClipboardList, RefreshCw, Lock, Loader2, XCircle, Inbox, ChevronDown, ChevronRight, Server, HelpCircle } from 'lucide-react';
export default function IntentsCard() {
const { isAuthenticated, makeAuthenticatedRequest, showToast } = useApp();
@@ -84,9 +84,7 @@ export default function IntentsCard() {
}
}, [isAuthenticated]);
- const handleShowDeleteModal = () => {
- window.dispatchEvent(new CustomEvent('openDeleteIntentsModal'));
- };
+
const toggleExpand = (intentId) => {
setExpandedIntents(prev => ({
@@ -139,13 +137,6 @@ export default function IntentsCard() {
>
-
diff --git a/web/src/components/cards/RolesCard.jsx b/web/src/components/cards/RolesCard.jsx
index fc284f7..92c3a02 100644
--- a/web/src/components/cards/RolesCard.jsx
+++ b/web/src/components/cards/RolesCard.jsx
@@ -1,51 +1,94 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useCallback, useEffect } from 'react';
import { useApp } from '../../context/AppContext';
-import { Shield, RefreshCw, ScrollText } from 'lucide-react';
+import { Shield, RefreshCw, ScrollText, Loader2, XCircle, Inbox, ChevronDown, ChevronRight } from 'lucide-react';
export default function RolesCard() {
- const { isAuthenticated, makeAuthenticatedRequest } = useApp();
- const [result, setResult] = useState('Authenticate to view roles...');
- const [resultClass, setResultClass] = useState('');
+ const { isAuthenticated, makeAuthenticatedRequest, showToast } = useApp();
+ const [roles, setRoles] = useState([]);
+ const [permissions, setPermissions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [viewMode, setViewMode] = useState('roles'); // 'roles' or 'permissions'
+ const [expandedItems, setExpandedItems] = useState({});
const getRoles = useCallback(async () => {
if (!isAuthenticated) return;
+ setLoading(true);
+ setError('');
+
try {
const response = await makeAuthenticatedRequest('/api/v1/roles');
const data = await response.json();
if (data.success) {
- setResult(JSON.stringify(data, null, 2));
- setResultClass('success');
+ const rolesList = data.data && data.data.roles ? data.data.roles : [];
+ setRoles(rolesList);
+ if (rolesList.length > 0) {
+ showToast('success', `Loaded ${rolesList.length} role(s)`);
+ } else {
+ showToast('info', 'No roles found');
+ }
} else {
- setResult('Error: ' + (data.error || data.message));
- setResultClass('error');
+ setError(data.error || data.message || 'Failed to load roles');
+ setRoles([]);
}
} catch (error) {
- setResult('Error: ' + error.message);
- setResultClass('error');
+ setError(error.message);
+ setRoles([]);
+ } finally {
+ setLoading(false);
}
- }, [isAuthenticated, makeAuthenticatedRequest]);
+ }, [isAuthenticated, makeAuthenticatedRequest, showToast]);
const getPermissions = useCallback(async () => {
if (!isAuthenticated) return;
+ setLoading(true);
+ setError('');
+
try {
const response = await makeAuthenticatedRequest('/api/v1/permissions');
const data = await response.json();
if (data.success) {
- setResult(JSON.stringify(data, null, 2));
- setResultClass('success');
+ const permissionsList = data.data && data.data.permissions ? data.data.permissions : [];
+ setPermissions(permissionsList);
+ if (permissionsList.length > 0) {
+ showToast('success', `Loaded ${permissionsList.length} permission(s)`);
+ } else {
+ showToast('info', 'No permissions found');
+ }
} else {
- setResult('Error: ' + (data.error || data.message));
- setResultClass('error');
+ setError(data.error || data.message || 'Failed to load permissions');
+ setPermissions([]);
}
} catch (error) {
- setResult('Error: ' + error.message);
- setResultClass('error');
+ setError(error.message);
+ setPermissions([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [isAuthenticated, makeAuthenticatedRequest, showToast]);
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ getRoles();
+ getPermissions();
}
- }, [isAuthenticated, makeAuthenticatedRequest]);
+ }, [isAuthenticated]);
+
+ const toggleExpand = (itemId) => {
+ setExpandedItems(prev => ({
+ ...prev,
+ [itemId]: !prev[itemId]
+ }));
+ };
+
+ const handleViewModeChange = (mode) => {
+ setViewMode(mode);
+ setExpandedItems({});
+ };
return (
@@ -74,9 +117,115 @@ export default function RolesCard() {
-
-
{result}
+ {/* View Mode Tabs */}
+
+
+
+
+ {loading && (
+
+
+
Loading {viewMode}...
+
+ )}
+
+ {!loading && error && (
+
+ )}
+
+ {!loading && !error && viewMode === 'roles' && roles.length === 0 && (
+
+
+
No roles found. {isAuthenticated ? 'Click the refresh button to load roles.' : 'Authenticate to view roles.'}
+
+ )}
+
+ {!loading && !error && viewMode === 'permissions' && permissions.length === 0 && (
+
+
+
No permissions found. {isAuthenticated ? 'Click the permissions button to load.' : 'Authenticate to view permissions.'}
+
+ )}
+
+ {!loading && !error && viewMode === 'roles' && roles.length > 0 && (
+
+ {roles.map((role) => (
+
+
toggleExpand(role.id)}>
+
+ {expandedItems[role.id] ? : }
+
+
+ {role.name}
+ {role.rolePolicy && (
+ {role.rolePolicy.length} policy(s)
+ )}
+
+
+
+ {expandedItems[role.id] && (
+
+
+
+ Role ID
+ {role.id}
+
+
+ Name
+ {role.name}
+
+ {role.description && (
+
+ Description
+ {role.description}
+
+ )}
+ {role.rolePolicy && role.rolePolicy.length > 0 && (
+
+
Role Policies
+
+ {role.rolePolicy.map((policy, index) => (
+ {policy.permissionKey}
+ ))}
+
+
+ )}
+
+
+ )}
+
+ ))}
+
+ )}
+
+ {!loading && !error && viewMode === 'permissions' && permissions.length > 0 && (
+
+ {permissions.map((permission) => (
+
+
+ {permission.key}
+
+ {permission.description && (
+
{permission.description}
+ )}
+
+ ))}
+
+ )}
);
diff --git a/web/src/components/cards/StrategiesCard.jsx b/web/src/components/cards/StrategiesCard.jsx
index 4a2955c..422646e 100644
--- a/web/src/components/cards/StrategiesCard.jsx
+++ b/web/src/components/cards/StrategiesCard.jsx
@@ -1,12 +1,14 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useApp } from '../../context/AppContext';
-import { Target, Download, Trash2, Save, FolderOpen, Loader2, XCircle, Inbox, ChevronDown, ChevronRight, HelpCircle } from 'lucide-react';
+import { Target, Download, Trash2, Save, FolderOpen, Loader2, XCircle, Inbox, ChevronDown, ChevronRight, HelpCircle, Pencil } from 'lucide-react';
export default function StrategiesCard() {
const { isAuthenticated, makeAuthenticatedRequest, showToast, strategyCounter, setStrategyCounter } = useApp();
const [strategies, setStrategies] = useState([]);
const [loadedStrategies, setLoadedStrategies] = useState([]);
const [expandedStrategies, setExpandedStrategies] = useState({});
+ const [editingStrategyId, setEditingStrategyId] = useState(null);
+ const [editStrategy, setEditStrategy] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -61,6 +63,67 @@ export default function StrategiesCard() {
}));
};
+ const beginEditStrategy = (strategy) => {
+ const selectors = (strategy.LabelSelectors || []).map(selector => ({
+ key: selector.key || '',
+ value: selector.value || ''
+ }));
+ if (selectors.length === 0) {
+ selectors.push({ key: '', value: '' });
+ }
+ setEditingStrategyId(strategy.ID);
+ setEditStrategy({
+ id: strategy.ID,
+ strategyNamespace: strategy.StrategyNamespace || '',
+ priority: strategy.Priority || 0,
+ executionTime: strategy.ExecutionTime || 0,
+ commandRegex: strategy.CommandRegex || '',
+ k8sNamespace: strategy.K8sNamespace ? strategy.K8sNamespace.join(', ') : '',
+ selectors
+ });
+ setExpandedStrategies(prev => ({
+ ...prev,
+ [strategy.ID]: true
+ }));
+ };
+
+ const cancelEditStrategy = () => {
+ setEditingStrategyId(null);
+ setEditStrategy(null);
+ };
+
+ const updateEditField = (field, value) => {
+ setEditStrategy(prev => ({
+ ...prev,
+ [field]: value
+ }));
+ };
+
+ const addEditSelector = () => {
+ setEditStrategy(prev => ({
+ ...prev,
+ selectors: [...prev.selectors, { key: '', value: '' }]
+ }));
+ };
+
+ const removeEditSelector = (index) => {
+ setEditStrategy(prev => {
+ const selectors = prev.selectors.filter((_, i) => i !== index);
+ if (selectors.length === 0) {
+ selectors.push({ key: '', value: '' });
+ }
+ return { ...prev, selectors };
+ });
+ };
+
+ const updateEditSelector = (index, field, value) => {
+ setEditStrategy(prev => {
+ const selectors = [...prev.selectors];
+ selectors[index] = { ...selectors[index], [field]: value };
+ return { ...prev, selectors };
+ });
+ };
+
const addStrategy = () => {
const newId = strategyCounter + 1;
setStrategyCounter(newId);
@@ -179,8 +242,86 @@ export default function StrategiesCard() {
}
};
- const handleShowDeleteModal = () => {
- window.dispatchEvent(new CustomEvent('openDeleteStrategyModal'));
+ const handleDeleteStrategy = async (strategyId) => {
+ if (!window.confirm('Are you sure you want to delete this strategy? This will also delete all associated intents.')) {
+ return;
+ }
+
+ try {
+ const response = await makeAuthenticatedRequest('/api/v1/strategies', {
+ method: 'DELETE',
+ body: JSON.stringify({ strategyId })
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ showToast('success', 'Strategy deleted successfully');
+ getStrategies();
+ window.dispatchEvent(new CustomEvent('refreshIntents'));
+ } else {
+ showToast('error', data.error || data.message || 'Failed to delete strategy');
+ }
+ } catch (error) {
+ showToast('error', 'Error: ' + error.message);
+ }
+ };
+
+ const handleUpdateStrategy = async () => {
+ if (!editStrategy) {
+ return;
+ }
+
+ const payload = {
+ strategyId: editStrategy.id
+ };
+
+ if (editStrategy.strategyNamespace.trim()) {
+ payload.strategyNamespace = editStrategy.strategyNamespace.trim();
+ }
+
+ if (editStrategy.priority !== '') {
+ payload.priority = parseInt(editStrategy.priority, 10);
+ }
+
+ if (editStrategy.executionTime !== '') {
+ payload.executionTime = parseInt(editStrategy.executionTime, 10);
+ }
+
+ if (editStrategy.commandRegex.trim()) {
+ payload.commandRegex = editStrategy.commandRegex.trim();
+ }
+
+ if (editStrategy.k8sNamespace.trim()) {
+ payload.k8sNamespace = editStrategy.k8sNamespace.split(',').map(ns => ns.trim()).filter(ns => ns);
+ }
+
+ const labelSelectors = editStrategy.selectors
+ .filter(s => s.key.trim() && s.value.trim())
+ .map(s => ({ key: s.key.trim(), value: s.value.trim() }));
+
+ if (labelSelectors.length > 0) {
+ payload.labelSelectors = labelSelectors;
+ }
+
+ try {
+ const response = await makeAuthenticatedRequest('/api/v1/strategies', {
+ method: 'PUT',
+ body: JSON.stringify(payload)
+ });
+
+ const data = await response.json();
+ if (data.success) {
+ showToast('success', 'Strategy updated successfully');
+ cancelEditStrategy();
+ getStrategies();
+ window.dispatchEvent(new CustomEvent('refreshIntents'));
+ } else {
+ showToast('error', data.error || data.message || 'Failed to update strategy');
+ }
+ } catch (error) {
+ showToast('error', 'Error: ' + error.message);
+ }
};
return (
@@ -207,13 +348,6 @@ export default function StrategiesCard() {
>
-
diff --git a/web/src/components/cards/UsersCard.jsx b/web/src/components/cards/UsersCard.jsx
index 8c3a51d..bc37b9b 100644
--- a/web/src/components/cards/UsersCard.jsx
+++ b/web/src/components/cards/UsersCard.jsx
@@ -1,31 +1,56 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useCallback, useEffect } from 'react';
import { useApp } from '../../context/AppContext';
-import { Users, RefreshCw } from 'lucide-react';
+import { Users, RefreshCw, Loader2, XCircle, Inbox, ChevronDown, ChevronRight } from 'lucide-react';
export default function UsersCard() {
- const { isAuthenticated, makeAuthenticatedRequest } = useApp();
- const [result, setResult] = useState('Authenticate to manage users...');
- const [resultClass, setResultClass] = useState('');
+ const { isAuthenticated, makeAuthenticatedRequest, showToast } = useApp();
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [expandedUsers, setExpandedUsers] = useState({});
const getUsers = useCallback(async () => {
if (!isAuthenticated) return;
+ setLoading(true);
+ setError('');
+
try {
const response = await makeAuthenticatedRequest('/api/v1/users');
const data = await response.json();
if (data.success) {
- setResult(JSON.stringify(data, null, 2));
- setResultClass('success');
+ const usersList = data.data && data.data.users ? data.data.users : [];
+ setUsers(usersList);
+ if (usersList.length > 0) {
+ showToast('success', `Loaded ${usersList.length} user(s)`);
+ } else {
+ showToast('info', 'No users found');
+ }
} else {
- setResult('Error: ' + (data.error || data.message));
- setResultClass('error');
+ setError(data.error || data.message || 'Failed to load users');
+ setUsers([]);
}
} catch (error) {
- setResult('Error: ' + error.message);
- setResultClass('error');
+ setError(error.message);
+ setUsers([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [isAuthenticated, makeAuthenticatedRequest, showToast]);
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ getUsers();
}
- }, [isAuthenticated, makeAuthenticatedRequest]);
+ }, [isAuthenticated]);
+
+ const toggleExpand = (userId) => {
+ setExpandedUsers(prev => ({
+ ...prev,
+ [userId]: !prev[userId]
+ }));
+ };
return (
@@ -46,9 +71,77 @@ export default function UsersCard() {
-
+ {loading && (
+
+ )}
+
+ {!loading && error && (
+
+ )}
+
+ {!loading && !error && users.length === 0 && (
+
+
+
No users found. {isAuthenticated ? 'Click the refresh button to load users.' : 'Authenticate to view users.'}
+
+ )}
+
+ {!loading && !error && users.length > 0 && (
+
+ {users.map((user) => (
+
+
toggleExpand(user.id)}>
+
+ {expandedUsers[user.id] ? : }
+
+
+ {user.username}
+ {user.roles && user.roles.length > 0 && (
+ {user.roles[0]}
+ )}
+
+
+
+ {expandedUsers[user.id] && (
+
+
+
+ User ID
+ {user.id}
+
+
+ Username
+ {user.username}
+
+ {user.status !== undefined && (
+
+ Status
+ {user.status}
+
+ )}
+ {user.roles && user.roles.length > 0 && (
+
+
Roles
+
+ {user.roles.map((role, index) => (
+ {role}
+ ))}
+
+
+ )}
+
+
+ )}
+
+ ))}
+
+ )}
);
diff --git a/web/src/components/modals/DeleteIntentsModal.jsx b/web/src/components/modals/DeleteIntentsModal.jsx
deleted file mode 100644
index 223e891..0000000
--- a/web/src/components/modals/DeleteIntentsModal.jsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { useApp } from '../../context/AppContext';
-import { Trash2, ClipboardList } from 'lucide-react';
-
-export default function DeleteIntentsModal() {
- const { makeAuthenticatedRequest, showToast } = useApp();
- const [isOpen, setIsOpen] = useState(false);
- const [intentIds, setIntentIds] = useState('');
-
- useEffect(() => {
- const handleOpen = () => {
- setIsOpen(true);
- };
-
- window.addEventListener('openDeleteIntentsModal', handleOpen);
- return () => window.removeEventListener('openDeleteIntentsModal', handleOpen);
- }, []);
-
- useEffect(() => {
- const handleKeyDown = (e) => {
- if (e.key === 'Escape' && isOpen) {
- handleClose();
- }
- };
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [isOpen]);
-
- const handleClose = () => {
- setIsOpen(false);
- setIntentIds('');
- };
-
- const handleOverlayClick = (e) => {
- if (e.target === e.currentTarget) {
- handleClose();
- }
- };
-
- const handleDelete = async () => {
- if (!intentIds.trim()) {
- showToast('error', 'Please enter intent IDs');
- return;
- }
-
- const ids = intentIds.split(',').map(id => id.trim()).filter(id => id);
-
- if (ids.length === 0) {
- showToast('error', 'No valid intent IDs provided');
- return;
- }
-
- try {
- const response = await makeAuthenticatedRequest('/api/v1/intents', {
- method: 'DELETE',
- body: JSON.stringify({ intentIds: ids })
- });
-
- const data = await response.json();
-
- if (data.success) {
- showToast('success', `Deleted ${ids.length} intent(s)`);
- handleClose();
- window.dispatchEvent(new CustomEvent('refreshIntents'));
- } else {
- showToast('error', data.error || data.message || 'Failed to delete intents');
- }
- } catch (error) {
- showToast('error', 'Error: ' + error.message);
- }
- };
-
- if (!isOpen) return null;
-
- return (
-
-
-
-
-
- Delete Schedule Intents
-
- ×
-
-
-
-
-
-
-
-
-
- Delete Intents
-
-
-
-
-
- );
-}
diff --git a/web/src/components/modals/DeleteStrategyModal.jsx b/web/src/components/modals/DeleteStrategyModal.jsx
deleted file mode 100644
index 051a125..0000000
--- a/web/src/components/modals/DeleteStrategyModal.jsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { useApp } from '../../context/AppContext';
-import { Trash2, Target } from 'lucide-react';
-
-export default function DeleteStrategyModal() {
- const { makeAuthenticatedRequest, showToast } = useApp();
- const [isOpen, setIsOpen] = useState(false);
- const [strategyId, setStrategyId] = useState('');
-
- useEffect(() => {
- const handleOpen = () => {
- setIsOpen(true);
- };
-
- window.addEventListener('openDeleteStrategyModal', handleOpen);
- return () => window.removeEventListener('openDeleteStrategyModal', handleOpen);
- }, []);
-
- useEffect(() => {
- const handleKeyDown = (e) => {
- if (e.key === 'Escape' && isOpen) {
- handleClose();
- }
- };
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [isOpen]);
-
- const handleClose = () => {
- setIsOpen(false);
- setStrategyId('');
- };
-
- const handleOverlayClick = (e) => {
- if (e.target === e.currentTarget) {
- handleClose();
- }
- };
-
- const handleDelete = async () => {
- if (!strategyId.trim()) {
- showToast('error', 'Please enter a strategy ID');
- return;
- }
-
- try {
- const response = await makeAuthenticatedRequest('/api/v1/strategies', {
- method: 'DELETE',
- body: JSON.stringify({ strategyId: strategyId.trim() })
- });
-
- const data = await response.json();
-
- if (data.success) {
- showToast('success', 'Strategy deleted successfully');
- handleClose();
- window.dispatchEvent(new CustomEvent('refreshStrategies'));
- window.dispatchEvent(new CustomEvent('refreshIntents'));
- } else {
- showToast('error', data.error || data.message || 'Failed to delete strategy');
- }
- } catch (error) {
- showToast('error', 'Error: ' + error.message);
- }
- };
-
- if (!isOpen) return null;
-
- return (
-
-
-
-
-
- Delete Schedule Strategy
-
- ×
-
-
-
-
-
setStrategyId(e.target.value)}
- />
-
-
-
-
- Delete Strategy
-
-
-
-
-
- );
-}
diff --git a/web/src/context/AppContext.jsx b/web/src/context/AppContext.jsx
index 51b1d74..67fce0c 100644
--- a/web/src/context/AppContext.jsx
+++ b/web/src/context/AppContext.jsx
@@ -31,16 +31,18 @@ export function AppProvider({ children }) {
// Toast notifications
const showToast = useCallback((type, message) => {
const id = Date.now();
- setToasts(prev => [...prev, { id, type, message }]);
- setTimeout(() => {
- setToasts(prev => prev.filter(t => t.id !== id));
- }, 4000);
+ const toast = { id, type, message, timestamp: new Date().toISOString() };
+ setToasts(prev => [...prev, toast].slice(-50));
}, []);
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
+ const clearToasts = useCallback(() => {
+ setToasts([]);
+ }, []);
+
// Authentication
const login = useCallback((token) => {
setJwtToken(token);
@@ -112,6 +114,7 @@ export function AppProvider({ children }) {
toasts,
showToast,
removeToast,
+ clearToasts,
login,
logout,
getApiUrl,
diff --git a/web/src/styles/index.css b/web/src/styles/index.css
index 8824002..d65ae3a 100644
--- a/web/src/styles/index.css
+++ b/web/src/styles/index.css
@@ -848,6 +848,44 @@ body {
background: rgba(255, 71, 87, 0.1);
}
+.danger-btn-small {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ background: transparent;
+ border: 1px solid var(--accent-danger);
+ border-radius: var(--radius-sm);
+ color: var(--accent-danger);
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.danger-btn-small:hover {
+ background: rgba(255, 71, 87, 0.1);
+ transform: translateY(-1px);
+}
+
+.secondary-btn-small {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ background: transparent;
+ border: 1px solid var(--accent-secondary);
+ border-radius: var(--radius-sm);
+ color: var(--accent-secondary);
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.secondary-btn-small:hover {
+ background: rgba(0, 212, 255, 0.1);
+ transform: translateY(-1px);
+}
+
/* Auto Refresh Toggle */
.auto-refresh-toggle {
display: flex;
@@ -1313,73 +1351,171 @@ body {
color: var(--accent-primary);
}
-/* Toast Notifications */
-.toast-container {
+/* Notification Center */
+.notification-center {
position: fixed;
bottom: var(--space-xl);
right: var(--space-xl);
+ z-index: 2000;
display: flex;
flex-direction: column;
+ align-items: flex-end;
gap: var(--space-sm);
- z-index: 2000;
}
-.toast {
- display: flex;
+.notification-toggle {
+ display: inline-flex;
align-items: center;
- gap: var(--space-sm);
- padding: var(--space-md) var(--space-lg);
- background: var(--bg-card);
+ gap: var(--space-xs);
+ padding: var(--space-sm) var(--space-md);
+ background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ cursor: pointer;
+ box-shadow: var(--shadow-md);
+ transition: var(--transition-fast);
+}
+
+.notification-toggle:hover {
+ border-color: var(--accent-secondary);
+}
+
+.notification-count {
+ background: var(--accent-danger);
+ color: var(--text-primary);
+ border-radius: 999px;
+ padding: 0 6px;
+ font-size: 0.65rem;
+ font-weight: 700;
+}
+
+.notification-panel {
+ width: 320px;
+ max-height: 360px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
- animation: toastIn 0.3s ease;
- min-width: 280px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
}
-@keyframes toastIn {
- from {
- opacity: 0;
- transform: translateX(100%);
- }
- to {
- opacity: 1;
- transform: translateX(0);
- }
+.notification-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-sm) var(--space-md);
+ background: var(--bg-elevated);
+ border-bottom: 1px solid var(--border-color);
}
-.toast.success {
- border-color: var(--accent-success);
+.notification-title {
+ font-family: var(--font-display);
+ font-size: 0.75rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
}
-.toast.error {
- border-color: var(--accent-danger);
+.notification-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xs);
}
-.toast.info {
+.notification-clear,
+.notification-close {
+ background: transparent;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ padding: 2px 6px;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.notification-clear:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.notification-clear:hover:not(:disabled),
+.notification-close:hover {
border-color: var(--accent-secondary);
+ color: var(--text-primary);
}
-.toast-icon {
- font-size: 1.2rem;
+.notification-empty {
+ padding: var(--space-md);
+ color: var(--text-muted);
+ font-size: 0.8rem;
}
-.toast-message {
- flex: 1;
- font-size: 0.85rem;
+.notification-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ padding: var(--space-sm);
+ overflow-y: auto;
+}
+
+.notification-item {
+ display: flex;
+ gap: var(--space-sm);
+ padding: var(--space-sm);
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
color: var(--text-primary);
}
-.toast-close {
- background: none;
+.notification-item.success {
+ border-color: rgba(0, 255, 136, 0.5);
+}
+
+.notification-item.error {
+ border-color: rgba(255, 71, 87, 0.6);
+}
+
+.notification-item.info {
+ border-color: rgba(0, 212, 255, 0.6);
+}
+
+.notification-icon {
+ font-size: 1rem;
+ margin-top: 2px;
+}
+
+.notification-body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.notification-message {
+ font-size: 0.8rem;
+ line-height: 1.4;
+}
+
+.notification-time {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+}
+
+.notification-dismiss {
+ background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1rem;
- padding: 0;
+ padding: 0 4px;
}
-.toast-close:hover {
+.notification-dismiss:hover {
color: var(--text-primary);
}
@@ -1424,14 +1560,15 @@ body {
flex-direction: column;
}
- .toast-container {
+ .notification-center {
left: var(--space-md);
right: var(--space-md);
bottom: var(--space-md);
+ align-items: stretch;
}
- .toast {
- min-width: unset;
+ .notification-panel {
+ width: 100%;
}
}
@@ -2493,7 +2630,7 @@ body {
justify-content: space-between;
align-items: center;
padding: var(--space-md);
- cursor: pointer;
+ cursor: default;
background: var(--bg-elevated);
transition: var(--transition-fast);
}
@@ -2506,6 +2643,7 @@ body {
display: flex;
align-items: center;
gap: var(--space-sm);
+ cursor: pointer;
}
.strategy-id {
@@ -2525,6 +2663,7 @@ body {
.strategy-loaded-summary {
display: flex;
+ align-items: center;
gap: var(--space-md);
font-size: 0.8rem;
color: var(--text-muted);
@@ -2541,4 +2680,168 @@ body {
padding: var(--space-md);
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
+}
+
+.strategy-edit-form {
+ margin-top: var(--space-md);
+ padding-top: var(--space-md);
+ border-top: 1px solid var(--border-color);
+}
+
+.strategy-edit-form h4 {
+ margin-bottom: var(--space-sm);
+ font-size: 0.9rem;
+ color: var(--text-primary);
+}
+
+.users-list,
+.roles-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-sm);
+}
+
+.user-item,
+.role-item {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+ transition: var(--transition-base);
+}
+
+.user-item:hover,
+.role-item:hover {
+ border-color: var(--accent-secondary);
+ box-shadow: 0 0 10px rgba(0, 212, 255, 0.1);
+}
+
+.user-header,
+.role-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-md);
+ cursor: pointer;
+ background: var(--bg-elevated);
+ transition: var(--transition-fast);
+}
+
+.user-header:hover,
+.role-header:hover {
+ background: var(--bg-card);
+}
+
+.user-info,
+.role-info {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+}
+
+.user-name,
+.role-name {
+ font-family: var(--font-mono);
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.user-role-badge {
+ padding: 0.2rem 0.5rem;
+ background: rgba(0, 212, 255, 0.15);
+ color: var(--accent-secondary);
+ border-radius: var(--radius-sm);
+ font-size: 0.7rem;
+ font-weight: 600;
+}
+
+.permission-count {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+}
+
+.user-details,
+.role-details {
+ padding: var(--space-md);
+ background: var(--bg-secondary);
+ border-top: 1px solid var(--border-color);
+}
+
+.permissions-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-xs);
+ margin-top: var(--space-xs);
+}
+
+.permission-badge {
+ padding: 0.2rem 0.5rem;
+ background: rgba(0, 255, 136, 0.12);
+ color: var(--accent-success);
+ border-radius: var(--radius-sm);
+ font-size: 0.7rem;
+ font-weight: 600;
+}
+
+.view-mode-tabs {
+ display: flex;
+ gap: var(--space-sm);
+ margin-bottom: var(--space-md);
+}
+
+.view-mode-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-xs);
+ padding: var(--space-xs) var(--space-md);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: var(--transition-fast);
+}
+
+.view-mode-tab.active {
+ border-color: var(--accent-primary);
+ color: var(--text-primary);
+ background: rgba(0, 255, 136, 0.08);
+}
+
+.view-mode-tab:hover {
+ border-color: var(--accent-secondary);
+ color: var(--text-primary);
+}
+
+.permissions-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: var(--space-sm);
+}
+
+.permission-item {
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-md);
+ padding: var(--space-md);
+ transition: var(--transition-base);
+}
+
+.permission-item:hover {
+ border-color: var(--accent-tertiary);
+ box-shadow: 0 0 10px rgba(124, 58, 237, 0.15);
+}
+
+.permission-name {
+ font-family: var(--font-mono);
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.permission-description {
+ margin-top: var(--space-xs);
+ color: var(--text-muted);
+ font-size: 0.75rem;
+ line-height: 1.4;
}
\ No newline at end of file