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 && ( +
+ +

Error: {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() { > - +
@@ -436,6 +591,91 @@ export default function StrategiesCard() { )} + + {editingStrategyId === strategy.ID && editStrategy && ( +
+

Edit Strategy

+
+
+ + updateEditField('strategyNamespace', e.target.value)} + /> +
+
+ + updateEditField('priority', e.target.value)} + /> +
+
+ + updateEditField('executionTime', e.target.value)} + /> +
+
+ + updateEditField('commandRegex', e.target.value)} + /> +
+
+ + updateEditField('k8sNamespace', e.target.value)} + /> +
+
+ +
+ {editStrategy.selectors.map((selector, index) => ( +
+ updateEditSelector(index, 'key', e.target.value)} + /> + updateEditSelector(index, 'value', e.target.value)} + /> + +
+ ))} +
+ +
+
+
+ + +
+
+ )} )} 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() {
-
-
{result}
-
+ {loading && ( +
+ +

Loading users...

+
+ )} + + {!loading && error && ( +
+ +

Error: {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 -

- -
-
-
- - -
-
-
- -
-
-
-
- ); -} 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)} - /> -
-
-
- -
-
-
-
- ); -} 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