Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ _Avoid_: free workspace, trial period (for the state), Free plan (bare, for this

The platform's fixed grace timeline that starts the moment a Workspace Subscription expires: the workspace is suspended immediately, the warning escalates as the countdown runs, and the workspace's resources are permanently deleted when it ends. Both roads into expiry — failed renewal payment and cancelled-then-lapsed — join the same countdown. The Billing Area surfaces it as a destructive warning carrying the stage's next deadline — the suspension date while a cancelled subscription's paid period still runs, the deletion date once expiry has passed; renewing (or resuming, before expiry) exits the countdown.

_Avoid_: grace period (as the user-facing name), debt period, deletion schedule.
_Avoid_: grace period (as the user-facing name), debt period, deletion schedule, paused (for the suspended workspace).

### Pay-As-You-Go (PAYG)

Expand Down Expand Up @@ -714,16 +714,28 @@ _Avoid_: user currency preference, build-time currency.

### Notification Center

The user's single inbox for Notifications, opened from the App Sidebar's Notifications entry (below the Projects row). User-scoped and global: it aggregates messages across every Project rather than belonging to one. It is not the Deployment Task Dock and does not manage running tasks; it holds messages, not work.
The user's single inbox for Notifications, opened from the App Sidebar's Notifications entry (below the Projects row). Global across every Project in the current workspace rather than belonging to one: every Workspace Actor sees the same messages, and only read state is personal. It is not the Deployment Task Dock and does not manage running tasks; it holds messages, not work.

_Avoid_: task center, activity feed, message center, alerts panel.

### Notification

One message addressed to the current user in the Notification Center: a system event (a deployment outcome, a database event), a billing or quota event, or a product announcement. Persistent and individually read/unread, which distinguishes it from a toast (ephemeral feedback that vanishes on its own); a Notification names its source Project when it has one.
One message addressed to the current user in the Notification Center: a billing, subscription, or quota event, or a platform announcement, each carrying a Notification Severity. Persistent and individually read/unread — read state is per message and per user, never a workspace-shared fact — which distinguishes it from a toast (ephemeral feedback that vanishes on its own); a Notification names its source Project when it has one. A Notification originates from the platform or from Brain itself; the two read identically in the Notification Center, though a platform-origin message may be withdrawn or revived by the platform when its underlying condition changes.

_Avoid_: alert, toast (for persistent items), event (for the user-facing message).

### Notification Severity

How much a Notification's message matters to the reader, on three levels — **critical** (something is already suspended or faces deletion), **warning** (a threshold was crossed or a deadline approaches; action prevents the next stage), **info** (a receipt, a hint, or an announcement; nothing to fix). Derived from what the message is about, never chosen per message, and shown without visual escalation: a critical item is marked, not shouted.

_Avoid_: priority, importance (the platform CR field), level, tone.

### Status Hint

The one banner at the top of the content area that explains a billing state while it holds — payment-due (under the Deletion Countdown), Account Debt, a full workspace quota, or an Active Free Trial about to end — and offers the way out. It is a state, not a message: it appears and vanishes with the condition, writes nothing to the Notification Center, and only the most severe holding state shows. The destructive states cannot be dismissed; a dismissed quota or trial hint stays hidden until its state ends and re-enters.

_Avoid_: alert bar, global notification, sticky toast, warning strip (as the concept's name).

## Design System

### Component Registry
Expand Down Expand Up @@ -760,7 +772,7 @@ _Avoid_: indicator capsule, FAB.

### Dev Mock

A dev/demo-only mode in which one feature's API answers are served from fixtures according to the selected Mock Scenario. Its state lives outside the dev tweaks panel — the panel is only its remote control, never the source of truth — which separates it from a tweak, an override value the panel owns. While a Dev Mock is enabled, the pages it covers show fixture data, not real state.
A dev/demo-only mode in which a feature's API answers are served from fixtures according to the selected Mock Scenario. One Dev Mock answers for every surface that derives from the same facts — the billing mock also serves the billing-born Notifications and, through them, the Status Hint — so those surfaces can never disagree; surfaces with unrelated facts (platform-origin Notifications, a Deployment Task Timeline, a Conversation) get Dev Mocks of their own, and independent Dev Mocks compose. Its state lives outside the dev tweaks panel — the panel is only its remote control, never the source of truth — which separates it from a tweak, an override value the panel owns. While a Dev Mock is enabled, the pages it covers show fixture data, not real state.

_Avoid_: mock group, mock tweak, mock override.

Expand Down
9 changes: 6 additions & 3 deletions apps/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"sealos/api/route/db"
"sealos/api/route/health"
"sealos/api/route/k8s"
"sealos/api/route/notification"
"sealos/api/route/telemetry"
)

Expand Down Expand Up @@ -70,6 +71,7 @@ func main() {
k8s.RegisterExecWebSocket(router)
ap.Register(api)
db.Register(api)
notification.Register(api)
telemetry.Register(api)

fmt.Printf("Server listening on :%s\n", port)
Expand Down Expand Up @@ -346,9 +348,10 @@ func addLogsQueryExamples(_ *huma.OpenAPI, op *huma.Operation) {
// those paths and internally appends the slash so chi can match.
func appendSlashForGroupRoots(next http.Handler) http.Handler {
roots := map[string]bool{
"/api/ap/v1alpha1": true,
"/api/db/v1alpha1": true,
"/api/k8s/v1alpha1": true,
"/api/ap/v1alpha1": true,
"/api/db/v1alpha1": true,
"/api/k8s/v1alpha1": true,
notification.BasePath: true,
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if roots[r.URL.Path] {
Expand Down
104 changes: 104 additions & 0 deletions apps/api/route/notification/routes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Package notification exposes the Notification Center's platform stream:
// a read proxy over the upstream Notification CRs of the caller's namespace,
// authenticated with the caller's own kubeconfig bearer token (no standing
// credentials on the Brain side), plus the desktop-compatible mark-read patch.
package notification

import (
"context"
"net/http"

"github.com/danielgtaylor/huma/v2"
apierrors "k8s.io/apimachinery/pkg/api/errors"

"sealos/api/middleware"
notificationsvc "sealos/api/service/notification"
)

// BasePath is the group root; `main.go` accepts it with or without the slash.
const BasePath = "/api/notification/v1alpha1"

// Service seams, swapped by route tests for a fake cluster.
var (
listNotifications = notificationsvc.List
markNotificationRead = notificationsvc.MarkRead
)

// Register adds the Notification API routes to the Huma API.
func Register(api huma.API) {
grp := huma.NewGroup(api, BasePath)
registerList(grp)
registerMarkRead(grp)
}

func registerList(grp huma.API) {
type listInput struct {
middleware.AuthInput
Namespace string `query:"namespace" doc:"Namespace (default from kubeconfig)"`
}
type listOutput struct {
Body notificationsvc.ListResult
}

huma.Register(grp, huma.Operation{
OperationID: "notification-list",
Method: http.MethodGet,
Path: "/",
Summary: "List platform Notifications",
Description: "List the upstream Notification CRs (`notifications.notification.sealos.io/v1`) of the resolved namespace, newest first. The platform writes and withdraws these; Brain only reads them. `isRead` mirrors the CR's `isRead` label — the same state the Sealos desktop's own inbox shows.",
Tags: []string{"Notification"},
}, func(ctx context.Context, input *listInput) (*listOutput, error) {
_, cfg, err := middleware.RestConfigFromAuth(input.Authorization)
if err != nil {
return nil, huma.Error400BadRequest("invalid kubeconfig", err)
}
result, err := listNotifications(ctx, cfg, input.Namespace)
if err != nil {
return nil, mapK8sError("failed to list notifications", err)
}
return &listOutput{Body: *result}, nil
})
}

func registerMarkRead(grp huma.API) {
type markReadInput struct {
middleware.AuthInput
Name string `path:"name" doc:"Notification CR metadata.name"`
Namespace string `query:"namespace" doc:"Namespace (default from kubeconfig)"`
}
type markReadOutput struct {
Body notificationsvc.ReadResult
}

huma.Register(grp, huma.Operation{
OperationID: "notification-mark-read",
Method: http.MethodPatch,
Path: "/{name}/read",
Summary: "Mark a platform Notification read",
Description: "Merge-patch `metadata.labels.isRead: \"true\"` on one Notification CR — the write the Sealos desktop performs, so the desktop bell follows. Callers whose kubeconfig lacks patch permission (workspace Developers) receive 403; the Notification Center treats that as a best-effort skip because its own per-user receipt already records the read.",
Tags: []string{"Notification"},
}, func(ctx context.Context, input *markReadInput) (*markReadOutput, error) {
_, cfg, err := middleware.RestConfigFromAuth(input.Authorization)
if err != nil {
return nil, huma.Error400BadRequest("invalid kubeconfig", err)
}
result, err := markNotificationRead(ctx, cfg, input.Namespace, input.Name)
if err != nil {
return nil, mapK8sError("failed to mark notification read", err)
}
return &markReadOutput{Body: *result}, nil
})
}

func mapK8sError(message string, err error) error {
switch {
case apierrors.IsNotFound(err):
return huma.Error404NotFound("notification not found", err)
case apierrors.IsForbidden(err):
return huma.Error403Forbidden("notification access forbidden", err)
case apierrors.IsUnauthorized(err):
return huma.Error401Unauthorized("invalid kubeconfig", err)
default:
return huma.Error500InternalServerError(message, err)
}
}
Loading