Compare commits

...

45 Commits

Author SHA1 Message Date
Jaayden Halko f8ff538144 fix(site/src/modules/changelog): format changelog dialog imports 2026-04-05 12:01:31 +00:00
Jaayden Halko a0e1dd546d fix(site/src/modules/changelog): bound toast polling and remove dark classes 2026-04-05 11:45:27 +00:00
Jaayden Halko 960bb5562e fix(coderd/database/migrations): index changelog notification lookup 2026-04-05 11:17:58 +00:00
Jaayden Halko 1fd0d5d8ca fix(site/src/modules/changelog): format changelog toast poll delay 2026-04-05 11:00:18 +00:00
Jaayden Halko f1ec33f90b fix(changelog): keep polling and lock broadcast critical section 2026-04-05 10:47:39 +00:00
Jaayden Halko d8edde4800 fix(coderd/changelog): compare canonical semver versions 2026-04-04 22:50:44 +00:00
Jaayden Halko cb1390e6f1 Revert "fix: persist changelog template as inbox in migration"
This reverts commit 15b19a6b5e.
2026-04-04 22:37:10 +00:00
Jaayden Halko 15b19a6b5e fix: persist changelog template as inbox in migration 2026-04-04 22:08:04 +00:00
Jaayden Halko 35e534e1c2 test(enterprise/coderd): avoid assuming changelog template method value 2026-04-04 21:56:03 +00:00
Jaayden Halko 9e060370bc fix: continue changelog polling retries and finalize broadcast version 2026-04-04 21:40:30 +00:00
Jaayden Halko 8fa00d9066 fix: preserve changelog inbox semantics while keeping toast retries 2026-04-04 21:35:23 +00:00
Jaayden Halko 308b62eceb fix(site/src/modules/changelog): fallback changelog hero URL in dialog 2026-04-04 21:20:33 +00:00
Jaayden Halko 18f8f46de3 fix(coderd/changelog): skip already-notified users on retries 2026-04-04 21:02:08 +00:00
Jaayden Halko 8febb74228 fix: avoid changelog lock deadlock and auth asset fetches 2026-04-04 20:57:17 +00:00
Jaayden Halko 6ac2ddfb88 fix(coderd/changelog): keep partial enqueue failures retryable 2026-04-04 20:38:20 +00:00
Jaayden Halko 740c644190 fix: harden changelog broadcast retries and SDK path escaping 2026-04-04 20:21:11 +00:00
Jaayden Halko f84241b5b3 fix(cli): skip changelog broadcast when inbox is disabled 2026-04-04 20:02:50 +00:00
Jaayden Halko 804c2e8780 fix(site/src/modules/notifications): gate changelog actions by template id 2026-04-04 19:39:53 +00:00
Jaayden Halko 8a4a7bf214 fix(coderd/notifications): enforce changelog as inbox-only 2026-04-04 19:20:44 +00:00
Jaayden Halko f66bc36b87 fix: restore changelog notification delivery semantics 2026-04-04 18:54:52 +00:00
Jaayden Halko 8fffe8232f fix(notifications): keep changelog inbox-only without template method mutation
- route TemplateChangelog messages to inbox-only in StoreEnqueuer
- remove runtime template method mutation that set method=inbox
  (this breaks notification settings UIs that only support smtp/webhook)
- keep rollback/version guard logic in broadcast
2026-04-04 18:11:15 +00:00
Jaayden Halko 83771a5c12 fix(changelog): skip broadcast for rollbacks and same/newer versions
Compare semantic major.minor versions against the last-notified site
config value and only broadcast when the running version is newer.
This avoids duplicate broadcast waves during downgrades/rollbacks.
2026-04-04 17:34:17 +00:00
Jaayden Halko c54f7c9863 fix(site/changelog): retry unread-toast check after mount
Poll unread changelog notification a few times after mount so users who
open the dashboard before startup broadcast completes still receive the
one-time toast without manual refresh.
2026-04-04 16:59:15 +00:00
Jaayden Halko 21a0fe4941 fix(changelog): fail broadcast on enqueue errors and handle toast mark-read rejection
- return early from changelog broadcast when any enqueue fails so
  changelog_last_notified_version is not advanced on partial failure
- wrap changelog toast mark-read API call in try/catch/finally to avoid
  unhandled promise rejections and always invalidate notification cache
2026-04-04 16:23:02 +00:00
Jaayden Halko e19e14d8cb fix(changelog): address codex review feedback
- add embeddable hero assets so go:embed assets/* always matches
- make Store.Has surface initialization errors instead of swallowing them
- update sample entry image paths to bundled SVG assets
2026-04-04 15:49:39 +00:00
Jaayden Halko dcb9666dcd Merge remote-tracking branch 'origin/main' into notifications-d9f7 2026-04-03 15:56:53 +00:00
Jaayden Halko 836c372db6 fix(changelog): resolve migration collision and codex feedback
- renumber changelog template migration to 000462 to avoid duplicate
  migration version conflicts after merging main
- normalize changelog image asset paths to avoid assets/assets URL
  duplication and 404s
- ensure changelog notification template method is set to inbox at
  startup so broadcasts stay in-app only
2026-04-03 15:56:41 +00:00
Jaayden Halko 8fb44b0ce5 Merge branch 'main' into notifications-d9f7 2026-04-03 22:26:25 +07:00
Jaayden Halko 191f25db4c fix(site/modules/notifications): wrap inbox stories with changelog provider 2026-04-02 06:04:15 +00:00
Jaayden Halko fd107e4f84 fix(docs): include changelog API page in manifest 2026-04-02 04:21:44 +00:00
Jaayden Halko baf105f0f6 fix(docs): sync manifest paths for offline docs build 2026-04-02 04:15:00 +00:00
Jaayden Halko 57b7302a49 chore(coderd/apidoc): regenerate swagger after merge 2026-04-02 04:06:13 +00:00
Jaayden Halko d8658edd6e Merge branch 'main' into notifications-d9f7 2026-04-02 10:57:18 +07:00
Jaayden Halko f56c017ac0 Merge origin/main into notifications-d9f7 2026-04-01 18:54:46 +00:00
Jaayden Halko adac64f250 feat(changelog): add sample 2.31 changelog entry for testing
Adds a second sample changelog entry to exercise the multi-entry
list view, date-based sorting, and version filtering in the
changelog UI.
2026-02-15 04:14:30 +00:00
Jaayden Halko 273944fad6 fix(changelog): fix data race in broadcast and formatting
Move BroadcastChangelog goroutine to after newAPI() returns so that
options.Database is not read concurrently with coderd.New() writing
to it. Use coderAPI.Database (the dbauthz-wrapped version) instead.

Also accept biome formatter's line-wrapping for the changelog API
methods in api.ts.
2026-02-14 03:43:28 +00:00
Jaayden Halko d279b1039d fix(changelog): fix migrations and generated artifacts 2026-02-13 10:08:21 +00:00
Jaayden Halko 33642c10f2 chore: remove unused sqlc changelog queries
The broadcast logic uses raw SQL via sqlDB directly, so these
sqlc-generated queries are unnecessary. Removing them avoids
needing to run make gen just for dead code.
2026-02-13 08:09:57 +00:00
Jaayden Halko 6d33befdc3 feat(site): add changelog dialog and toast notification 2026-02-13 08:06:22 +00:00
Jaayden Halko 54dbf3fff0 feat(site): add changelog icon and badges to inbox notifications 2026-02-13 07:43:36 +00:00
Jaayden Halko a59ef7bf94 feat(changelog): add startup changelog broadcast to all users 2026-02-13 07:43:32 +00:00
Jaayden Halko 41f91e31e6 feat(coderd): add changelog API endpoints 2026-02-13 07:43:28 +00:00
Jaayden Halko 1b3ba81346 feat(site): add sonner toast infrastructure 2026-02-13 07:24:59 +00:00
Jaayden Halko c4cee8d002 feat(notifications): add changelog notification template and icon 2026-02-13 07:24:54 +00:00
Jaayden Halko 978b86a601 feat(changelog): add embedded changelog store package 2026-02-13 07:24:48 +00:00
46 changed files with 2174 additions and 28 deletions
+22
View File
@@ -63,6 +63,7 @@ import (
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/autobuild"
"github.com/coder/coder/v2/coderd/changelog"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/awsiamrds"
"github.com/coder/coder/v2/coderd/database/dbauthz"
@@ -1003,6 +1004,27 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
return xerrors.Errorf("create coder API: %w", err)
}
// Broadcast changelog notifications to all users for
// new versions. This must run after newAPI so that the
// database is wrapped with dbauthz.
if notificationsCfg.Inbox.Enabled.Value() {
changelogStore := changelog.NewStore()
go func() {
if err := changelog.BroadcastChangelog(
ctx,
logger.Named("changelog.broadcast"),
sqlDB,
coderAPI.Database,
enqueuer,
changelogStore,
); err != nil {
logger.Error(ctx, "failed to broadcast changelog", slog.Error(err))
}
}()
} else {
logger.Debug(ctx, "skipping changelog broadcast because inbox notifications are disabled")
}
if vals.Prometheus.Enable {
// Agent metrics require reference to the tailnet coordinator, so must be initiated after Coder API.
closeAgentsFunc, err := prometheusmetrics.Agents(ctx, logger, options.PrometheusRegistry, coderAPI.Database, &coderAPI.TailnetCoordinator, coderAPI.DERPMap, coderAPI.Options.AgentInactiveDisconnectTimeout, 0)
+157
View File
@@ -612,6 +612,121 @@ const docTemplate = `{
}
}
},
"/changelog": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Changelog"
],
"summary": "List changelog entries",
"operationId": "list-changelog-entries",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ListChangelogEntriesResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/changelog/assets/{path}": {
"get": {
"produces": [
"application/octet-stream"
],
"tags": [
"Changelog"
],
"summary": "Get changelog asset",
"operationId": "get-changelog-asset",
"parameters": [
{
"type": "string",
"description": "Asset path",
"name": "path",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/changelog/unread": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Changelog"
],
"summary": "Get unread changelog notification",
"operationId": "get-unread-changelog-notification",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UnreadChangelogNotificationResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/changelog/{version}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Changelog"
],
"summary": "Get changelog entry",
"operationId": "get-changelog-entry",
"parameters": [
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChangelogEntry"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/chats/insights/pull-requests": {
"get": {
"produces": [
@@ -14409,6 +14524,29 @@ const docTemplate = `{
}
}
},
"codersdk.ChangelogEntry": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"date": {
"type": "string"
},
"image_url": {
"type": "string"
},
"summary": {
"type": "string"
},
"title": {
"type": "string"
},
"version": {
"type": "string"
}
}
},
"codersdk.ChatConfig": {
"type": "object",
"properties": {
@@ -16583,6 +16721,17 @@ const docTemplate = `{
}
}
},
"codersdk.ListChangelogEntriesResponse": {
"type": "object",
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChangelogEntry"
}
}
}
},
"codersdk.ListInboxNotificationsResponse": {
"type": "object",
"properties": {
@@ -20912,6 +21061,14 @@ const docTemplate = `{
}
}
},
"codersdk.UnreadChangelogNotificationResponse": {
"type": "object",
"properties": {
"notification": {
"$ref": "#/definitions/codersdk.InboxNotification"
}
}
},
"codersdk.UpdateActiveTemplateVersion": {
"type": "object",
"required": [
+141
View File
@@ -529,6 +529,105 @@
}
}
},
"/changelog": {
"get": {
"produces": ["application/json"],
"tags": ["Changelog"],
"summary": "List changelog entries",
"operationId": "list-changelog-entries",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ListChangelogEntriesResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/changelog/assets/{path}": {
"get": {
"produces": ["application/octet-stream"],
"tags": ["Changelog"],
"summary": "Get changelog asset",
"operationId": "get-changelog-asset",
"parameters": [
{
"type": "string",
"description": "Asset path",
"name": "path",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK"
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/changelog/unread": {
"get": {
"produces": ["application/json"],
"tags": ["Changelog"],
"summary": "Get unread changelog notification",
"operationId": "get-unread-changelog-notification",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.UnreadChangelogNotificationResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/changelog/{version}": {
"get": {
"produces": ["application/json"],
"tags": ["Changelog"],
"summary": "Get changelog entry",
"operationId": "get-changelog-entry",
"parameters": [
{
"type": "string",
"description": "Version",
"name": "version",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ChangelogEntry"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/chats/insights/pull-requests": {
"get": {
"produces": ["application/json"],
@@ -12952,6 +13051,29 @@
}
}
},
"codersdk.ChangelogEntry": {
"type": "object",
"properties": {
"content": {
"type": "string"
},
"date": {
"type": "string"
},
"image_url": {
"type": "string"
},
"summary": {
"type": "string"
},
"title": {
"type": "string"
},
"version": {
"type": "string"
}
}
},
"codersdk.ChatConfig": {
"type": "object",
"properties": {
@@ -15049,6 +15171,17 @@
}
}
},
"codersdk.ListChangelogEntriesResponse": {
"type": "object",
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ChangelogEntry"
}
}
}
},
"codersdk.ListInboxNotificationsResponse": {
"type": "object",
"properties": {
@@ -19205,6 +19338,14 @@
}
}
},
"codersdk.UnreadChangelogNotificationResponse": {
"type": "object",
"properties": {
"notification": {
"$ref": "#/definitions/codersdk.InboxNotification"
}
}
},
"codersdk.UpdateActiveTemplateVersion": {
"type": "object",
"required": ["id"],
+192
View File
@@ -0,0 +1,192 @@
package coderd
import (
"io/fs"
"net/http"
"path"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/changelog"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/codersdk"
)
func changelogImageURL(image string) string {
assetPath := normalizeChangelogAssetPath(image)
if assetPath == "" {
return ""
}
return "/api/v2/changelog/assets/" + assetPath
}
func normalizeChangelogAssetPath(assetPath string) string {
assetPath = strings.TrimSpace(assetPath)
assetPath = strings.TrimPrefix(assetPath, "/")
assetPath = strings.TrimPrefix(assetPath, "assets/")
return assetPath
}
// listChangelogEntries lists the embedded changelog entries.
//
// @Summary List changelog entries
// @ID list-changelog-entries
// @Security CoderSessionToken
// @Produce json
// @Tags Changelog
// @Success 200 {object} codersdk.ListChangelogEntriesResponse
// @Router /changelog [get]
func (api *API) listChangelogEntries(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
entries, err := api.ChangelogStore.List()
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
resp := codersdk.ListChangelogEntriesResponse{
Entries: make([]codersdk.ChangelogEntry, 0, len(entries)),
}
for _, e := range entries {
imageURL := changelogImageURL(e.Image)
resp.Entries = append(resp.Entries, codersdk.ChangelogEntry{
Version: e.Version,
Title: e.Title,
Date: e.Date,
Summary: e.Summary,
ImageURL: imageURL,
})
}
httpapi.Write(ctx, rw, http.StatusOK, resp)
}
// changelogEntryByVersion returns a single changelog entry by version.
//
// @Summary Get changelog entry
// @ID get-changelog-entry
// @Security CoderSessionToken
// @Produce json
// @Tags Changelog
// @Param version path string true "Version"
// @Success 200 {object} codersdk.ChangelogEntry
// @Router /changelog/{version} [get]
func (api *API) changelogEntryByVersion(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
version := chi.URLParam(r, "version")
entry, err := api.ChangelogStore.Get(version)
if err != nil {
if _, listErr := api.ChangelogStore.List(); listErr != nil {
httpapi.InternalServerError(rw, listErr)
return
}
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: "Changelog entry not found.",
})
return
}
imageURL := changelogImageURL(entry.Image)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ChangelogEntry{
Version: entry.Version,
Title: entry.Title,
Date: entry.Date,
Summary: entry.Summary,
ImageURL: imageURL,
Content: entry.Content,
})
}
// changelogAsset serves embedded assets referenced by changelog entries.
//
// @Summary Get changelog asset
// @ID get-changelog-asset
// @Security CoderSessionToken
// @Produce octet-stream
// @Tags Changelog
// @Param path path string true "Asset path"
// @Success 200
// @Router /changelog/assets/{path} [get]
func (*API) changelogAsset(rw http.ResponseWriter, r *http.Request) {
assetPath := normalizeChangelogAssetPath(chi.URLParam(r, "*"))
if !fs.ValidPath(assetPath) {
http.NotFound(rw, r)
return
}
data, err := fs.ReadFile(changelog.FS, path.Join("assets", assetPath))
if err != nil {
http.NotFound(rw, r)
return
}
// Detect content type from the file extension.
switch strings.ToLower(path.Ext(assetPath)) {
case ".webp":
rw.Header().Set("Content-Type", "image/webp")
case ".png":
rw.Header().Set("Content-Type", "image/png")
case ".jpg", ".jpeg":
rw.Header().Set("Content-Type", "image/jpeg")
case ".svg":
rw.Header().Set("Content-Type", "image/svg+xml")
default:
rw.Header().Set("Content-Type", "application/octet-stream")
}
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(data)
}
// unreadChangelogNotification returns the most recent unread changelog inbox
// notification for the authenticated user.
//
// @Summary Get unread changelog notification
// @ID get-unread-changelog-notification
// @Security CoderSessionToken
// @Produce json
// @Tags Changelog
// @Success 200 {object} codersdk.UnreadChangelogNotificationResponse
// @Router /changelog/unread [get]
func (api *API) unreadChangelogNotification(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
apikey = httpmw.APIKey(r)
)
notifs, err := api.Database.GetFilteredInboxNotificationsByUserID(ctx, database.GetFilteredInboxNotificationsByUserIDParams{
UserID: apikey.UserID,
Templates: []uuid.UUID{notifications.TemplateChangelog},
ReadStatus: database.InboxNotificationReadStatusUnread,
LimitOpt: 1,
})
if err != nil {
api.Logger.Error(ctx, "failed to get unread changelog notification", slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get unread changelog notification.",
})
return
}
var notif *codersdk.InboxNotification
if len(notifs) > 0 {
converted := convertInboxNotificationResponse(ctx, api.Logger, notifs[0])
notif = &converted
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UnreadChangelogNotificationResponse{
Notification: notif,
})
}
View File
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="Coder 2.30 changelog hero">
<defs>
<linearGradient id="bg230" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#6D28D9" />
<stop offset="100%" stop-color="#7C3AED" />
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#bg230)" />
<text x="72" y="300" fill="#FFFFFF" font-family="Inter, Arial, sans-serif" font-size="72" font-weight="700">
What's new in Coder 2.30
</text>
</svg>

After

Width:  |  Height:  |  Size: 552 B

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="Coder 2.31 changelog hero">
<defs>
<linearGradient id="bg231" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#0EA5E9" />
<stop offset="100%" stop-color="#2563EB" />
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#bg231)" />
<text x="72" y="300" fill="#FFFFFF" font-family="Inter, Arial, sans-serif" font-size="72" font-weight="700">
What's new in Coder 2.31
</text>
</svg>

After

Width:  |  Height:  |  Size: 552 B

+236
View File
@@ -0,0 +1,236 @@
package changelog
import (
"context"
"database/sql"
"strings"
"github.com/google/uuid"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/notifications"
)
const changelogLastNotifiedSiteConfigKey = "changelog_last_notified_version"
// BroadcastChangelog sends a changelog notification to all active users for the
// current version, if it hasn't been sent yet.
//
// It acquires a Postgres advisory lock for HA safety.
func BroadcastChangelog(
ctx context.Context,
logger slog.Logger,
sqlDB *sql.DB,
store database.Store,
enqueuer notifications.Enqueuer,
changelogStore *Store,
) error {
version := buildinfo.Version()
if version == "" || version == "v0.0.0" {
// No version is attached, meaning this is a dev build outside CI.
return nil
}
majorMinor := strings.TrimPrefix(semver.MajorMinor(version), "v")
if majorMinor == "" {
return nil
}
hasEntry, err := changelogStore.Has(majorMinor)
if err != nil {
return xerrors.Errorf("check changelog entry for version %q: %w", majorMinor, err)
}
if !hasEntry {
logger.Debug(ctx, "no changelog entry for version", slog.F("version", majorMinor))
return nil
}
isAlreadyNotified := func(lastNotified string) bool {
if lastNotified == "" {
return false
}
currentVersion := canonicalSemverVersion(majorMinor)
lastNotifiedVersion := canonicalSemverVersion(lastNotified)
if currentVersion != "" && lastNotifiedVersion != "" {
return semver.Compare(currentVersion, lastNotifiedVersion) <= 0
}
return lastNotified == majorMinor
}
lockID := int64(database.LockIDChangelogBroadcast)
conn, err := sqlDB.Conn(ctx)
if err != nil {
return xerrors.Errorf("acquire db conn: %w", err)
}
defer conn.Close()
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", lockID); err != nil {
return xerrors.Errorf("acquire advisory lock: %w", err)
}
defer func() {
_, _ = conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", lockID)
}()
var lastNotified string
err = conn.QueryRowContext(ctx, "SELECT value FROM site_configs WHERE key = $1", changelogLastNotifiedSiteConfigKey).
Scan(&lastNotified)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("query last notified version: %w", err)
}
if isAlreadyNotified(lastNotified) {
logger.Debug(ctx,
"changelog already notified for this version or newer",
slog.F("version", majorMinor),
slog.F("last_notified_version", lastNotified),
)
return nil
}
entry, err := changelogStore.Get(majorMinor)
if err != nil {
return xerrors.Errorf("get changelog entry: %w", err)
}
labels := map[string]string{
"version": majorMinor,
"summary": entry.Summary,
}
alreadyNotifiedUsers, err := changelogNotifiedUsersByVersion(ctx, sqlDB, majorMinor)
if err != nil {
return xerrors.Errorf("list users already notified for version %s: %w", majorMinor, err)
}
const pageSize = 100
var afterID uuid.UUID
usersNotified := 0
enqueueFailures := 0
for {
//nolint:gocritic // This needs system access to list all active users.
users, err := store.GetUsers(dbauthz.AsSystemRestricted(ctx), database.GetUsersParams{
AfterID: afterID,
Status: []database.UserStatus{database.UserStatusActive},
IncludeSystem: false,
LimitOpt: pageSize,
})
if err != nil {
return xerrors.Errorf("list users: %w", err)
}
if len(users) == 0 {
break
}
for _, user := range users {
if _, ok := alreadyNotifiedUsers[user.ID]; ok {
continue
}
msgIDs, err := enqueuer.Enqueue(
//nolint:gocritic // Enqueueing notifications requires notifier permissions.
dbauthz.AsNotifier(ctx),
user.ID,
notifications.TemplateChangelog,
labels,
"changelog",
)
if err != nil {
enqueueFailures++
logger.Warn(ctx, "failed to enqueue changelog notification",
slog.F("user_id", user.ID),
slog.Error(err),
)
continue
}
if len(msgIDs) > 0 {
usersNotified++
alreadyNotifiedUsers[user.ID] = struct{}{}
}
}
afterID = users[len(users)-1].ID
if len(users) < pageSize {
break
}
}
if enqueueFailures > 0 {
logger.Warn(ctx, "changelog notifications had per-user enqueue failures",
slog.F("version", majorMinor),
slog.F("enqueue_failures", enqueueFailures),
slog.F("users_notified", usersNotified),
)
return nil
}
_, err = conn.ExecContext(ctx,
"INSERT INTO site_configs (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2 WHERE site_configs.key = $1",
changelogLastNotifiedSiteConfigKey,
majorMinor,
)
if err != nil {
return xerrors.Errorf("upsert last notified version: %w", err)
}
logger.Info(ctx, "changelog notifications sent",
slog.F("version", majorMinor),
slog.F("users_notified", usersNotified),
)
return nil
}
func canonicalSemverVersion(version string) string {
trimmed := strings.TrimSpace(strings.TrimPrefix(version, "v"))
if trimmed == "" {
return ""
}
parts := strings.Split(trimmed, ".")
switch len(parts) {
case 1:
trimmed += ".0.0"
case 2:
trimmed += ".0"
}
canonical := semver.Canonical("v" + trimmed)
if canonical == "" {
return ""
}
return canonical
}
func changelogNotifiedUsersByVersion(ctx context.Context, sqlDB *sql.DB, version string) (map[uuid.UUID]struct{}, error) {
rows, err := sqlDB.QueryContext(ctx, `
SELECT DISTINCT user_id
FROM notification_messages
WHERE notification_template_id = $1
AND payload -> 'labels' ->> 'version' = $2
`, notifications.TemplateChangelog, version)
if err != nil {
return nil, err
}
defer rows.Close()
users := make(map[uuid.UUID]struct{})
for rows.Next() {
var userID uuid.UUID
if err := rows.Scan(&userID); err != nil {
return nil, err
}
users[userID] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
}
+6
View File
@@ -0,0 +1,6 @@
package changelog
import "embed"
//go:embed entries/*.md assets/*
var FS embed.FS
+23
View File
@@ -0,0 +1,23 @@
---
version: "2.30"
title: "What's new in Coder 2.30"
date: "2025-07-08"
summary: "Dynamic parameters, prebuilt workspaces, and more."
image: "assets/2.30-hero.svg"
---
## Dynamic Parameters 🎨
Template parameters now support dynamic options, validation, and conditional display logic — all powered by Terraform. Admins can create richer, more adaptive template inputs without any frontend changes.
## Prebuilt Workspaces ⚡
Reduce workspace startup times by keeping a pool of pre-provisioned workspaces warm and ready. When a user creates a workspace, they get a prebuilt one instantly.
## MCP Integration 🤖
The Coder CLI now includes an MCP server that AI agents can use to interact with workspaces. Run `coder mcp server` to start the server and connect it to your AI tools.
## Inbox Notifications 📬
A new in-app notification inbox lets users see workspace events, account changes, and template updates without leaving the dashboard.
+23
View File
@@ -0,0 +1,23 @@
---
version: "2.31"
title: "What's new in Coder 2.31"
date: "2025-08-12"
summary: "AI task runner, enhanced workspace search, and devcontainer support."
image: "assets/2.31-hero.svg"
---
## AI Task Runner 🧠
Run AI-powered tasks directly in your workspaces. Coder Tasks let you describe what you want built in natural language — Coder spins up an isolated workspace, runs an AI coding agent, and delivers a pull request.
## Enhanced Workspace Search 🔍
The workspace list now supports rich filtering by template, status, owner, and last-used date. Pin your favorite workspaces and sort by any column.
## Devcontainer Support 📦
Templates can now reference devcontainer configurations. When a workspace starts, Coder automatically builds and runs the devcontainer defined in your repository — bringing full `devcontainer.json` compatibility to remote workspaces.
## Connection Diagnostics 🩺
A new `coder ping` command and dashboard health panel help you troubleshoot workspace connectivity issues. See latency, DERP relay paths, and direct connection status at a glance.
+155
View File
@@ -0,0 +1,155 @@
package changelog
import (
"bytes"
"io/fs"
"path"
"sort"
"strings"
"sync"
"golang.org/x/mod/semver"
"golang.org/x/xerrors"
"gopkg.in/yaml.v3"
)
// EntryMeta holds the YAML frontmatter of a changelog entry.
type EntryMeta struct {
Version string `yaml:"version" json:"version"`
Title string `yaml:"title" json:"title"`
Date string `yaml:"date" json:"date"`
Summary string `yaml:"summary" json:"summary"`
Image string `yaml:"image" json:"image"`
}
// Entry is a parsed changelog entry with body markdown.
type Entry struct {
EntryMeta
Content string `json:"content"`
}
// Store provides access to embedded changelog entries.
type Store struct {
once sync.Once
entries []Entry
byVer map[string]*Entry
err error
}
// NewStore creates a Store that lazily parses the embedded FS.
func NewStore() *Store {
return &Store{}
}
func (s *Store) init() {
s.once.Do(func() {
s.byVer = make(map[string]*Entry)
files, err := fs.ReadDir(FS, "entries")
if err != nil {
s.err = xerrors.Errorf("read entries dir: %w", err)
return
}
for _, f := range files {
if f.IsDir() || !strings.HasSuffix(f.Name(), ".md") {
continue
}
data, err := fs.ReadFile(FS, path.Join("entries", f.Name()))
if err != nil {
s.err = xerrors.Errorf("read %s: %w", f.Name(), err)
return
}
entry, err := ParseEntry(data)
if err != nil {
s.err = xerrors.Errorf("parse %s: %w", f.Name(), err)
return
}
s.entries = append(s.entries, *entry)
s.byVer[entry.Version] = &s.entries[len(s.entries)-1]
}
// Sort by date descending, then version descending as a tiebreaker.
sort.Slice(s.entries, func(i, j int) bool {
if s.entries[i].Date != s.entries[j].Date {
return s.entries[i].Date > s.entries[j].Date
}
vi := "v" + s.entries[i].Version
vj := "v" + s.entries[j].Version
return semver.Compare(vi, vj) > 0
})
// Rebuild map pointers after sort.
for i := range s.entries {
s.byVer[s.entries[i].Version] = &s.entries[i]
}
})
}
// List returns all entries sorted by date desc.
func (s *Store) List() ([]Entry, error) {
s.init()
if s.err != nil {
return nil, s.err
}
return s.entries, nil
}
// Get returns a single entry by version string (e.g. "2.30").
func (s *Store) Get(version string) (*Entry, error) {
s.init()
if s.err != nil {
return nil, s.err
}
e, ok := s.byVer[version]
if !ok {
return nil, xerrors.Errorf("changelog entry not found for version %s", version)
}
return e, nil
}
// Has reports whether an entry exists for the given version.
func (s *Store) Has(version string) (bool, error) {
s.init()
if s.err != nil {
return false, s.err
}
_, ok := s.byVer[version]
return ok, nil
}
// ParseEntry parses a changelog entry markdown file (YAML frontmatter + body).
func ParseEntry(data []byte) (*Entry, error) {
// Split frontmatter from body. Format: ---\nyaml\n---\nmarkdown
const delimiter = "---"
trimmed := bytes.TrimSpace(data)
if !bytes.HasPrefix(trimmed, []byte(delimiter)) {
return nil, xerrors.New("missing frontmatter delimiter")
}
rest := trimmed[len(delimiter):]
idx := bytes.Index(rest, []byte("\n"+delimiter))
if idx < 0 {
return nil, xerrors.New("missing closing frontmatter delimiter")
}
fmData := rest[:idx]
body := rest[idx+len("\n"+delimiter):]
var meta EntryMeta
if err := yaml.Unmarshal(fmData, &meta); err != nil {
return nil, xerrors.Errorf("unmarshal frontmatter: %w", err)
}
if meta.Version == "" {
return nil, xerrors.New("frontmatter missing required 'version' field")
}
return &Entry{
EntryMeta: meta,
Content: strings.TrimSpace(string(body)),
}, nil
}
+183
View File
@@ -0,0 +1,183 @@
package changelog_test
import (
"strings"
"testing"
"golang.org/x/mod/semver"
"github.com/coder/coder/v2/coderd/changelog"
)
func TestStoreList(t *testing.T) {
t.Parallel()
s := changelog.NewStore()
entries, err := s.List()
if err != nil {
t.Fatalf("List() error: %v", err)
}
if len(entries) == 0 {
t.Fatalf("List() returned no entries")
}
for i := 1; i < len(entries); i++ {
prev := entries[i-1]
curr := entries[i]
if prev.Date < curr.Date {
t.Fatalf("entries not sorted by date desc at index %d: %q < %q", i, prev.Date, curr.Date)
}
if prev.Date == curr.Date {
if semver.Compare("v"+prev.Version, "v"+curr.Version) < 0 {
t.Fatalf("entries not sorted by version desc at index %d: %q < %q", i, prev.Version, curr.Version)
}
}
}
}
func TestStoreGet(t *testing.T) {
t.Parallel()
s := changelog.NewStore()
entry, err := s.Get("2.30")
if err != nil {
t.Fatalf("Get(\"2.30\") error: %v", err)
}
if entry.Version != "2.30" {
t.Fatalf("Version = %q, want %q", entry.Version, "2.30")
}
if entry.Title != "What's new in Coder 2.30" {
t.Fatalf("Title = %q, want %q", entry.Title, "What's new in Coder 2.30")
}
if entry.Date != "2025-07-08" {
t.Fatalf("Date = %q, want %q", entry.Date, "2025-07-08")
}
if entry.Summary != "Dynamic parameters, prebuilt workspaces, and more." {
t.Fatalf("Summary = %q, want %q", entry.Summary, "Dynamic parameters, prebuilt workspaces, and more.")
}
if entry.Image != "assets/2.30-hero.svg" {
t.Fatalf("Image = %q, want %q", entry.Image, "assets/2.30-hero.svg")
}
if strings.Contains(entry.Content, "version:") {
t.Fatalf("Content unexpectedly contains frontmatter")
}
if !strings.HasPrefix(entry.Content, "## Dynamic Parameters") {
got := entry.Content
if len(got) > 50 {
got = got[:50]
}
t.Fatalf("Content does not start with expected heading; got %q", got)
}
for _, want := range []string{
"## Dynamic Parameters",
"## Prebuilt Workspaces",
"## MCP Integration",
"coder mcp server",
"## Inbox Notifications",
} {
if !strings.Contains(entry.Content, want) {
t.Fatalf("Content missing %q", want)
}
}
}
func TestStoreGet_NotFound(t *testing.T) {
t.Parallel()
s := changelog.NewStore()
entry, err := s.Get("99.99")
if err == nil {
t.Fatalf("Get(\"99.99\") expected error")
}
if entry != nil {
t.Fatalf("Get(\"99.99\") entry = %#v, want nil", entry)
}
if !strings.Contains(err.Error(), "99.99") {
t.Fatalf("error %q does not mention requested version", err.Error())
}
}
func TestStoreHas(t *testing.T) {
t.Parallel()
s := changelog.NewStore()
has230, err := s.Has("2.30")
if err != nil {
t.Fatalf("Has(\"2.30\") error: %v", err)
}
if !has230 {
t.Fatalf("Has(\"2.30\") = false, want true")
}
has9999, err := s.Has("99.99")
if err != nil {
t.Fatalf("Has(\"99.99\") error: %v", err)
}
if has9999 {
t.Fatalf("Has(\"99.99\") = true, want false")
}
}
func TestParseEntry(t *testing.T) {
t.Parallel()
t.Run("valid", func(t *testing.T) {
t.Parallel()
data := []byte(`---
version: "1.0"
title: "Title"
date: "2025-01-01"
summary: "Summary"
image: "assets/1.0.webp"
---
Hello world.
`)
entry, err := changelog.ParseEntry(data)
if err != nil {
t.Fatalf("parseEntry error: %v", err)
}
if entry.Version != "1.0" {
t.Fatalf("Version = %q, want %q", entry.Version, "1.0")
}
if entry.Content != "Hello world." {
t.Fatalf("Content = %q, want %q", entry.Content, "Hello world.")
}
})
t.Run("missing_opening_delimiter", func(t *testing.T) {
t.Parallel()
data := []byte("version: \"1.0\"\n---\nHello")
_, err := changelog.ParseEntry(data)
if err == nil {
t.Fatalf("expected error")
}
})
t.Run("missing_closing_delimiter", func(t *testing.T) {
t.Parallel()
data := []byte("---\nversion: \"1.0\"\nHello")
_, err := changelog.ParseEntry(data)
if err == nil {
t.Fatalf("expected error")
}
})
t.Run("missing_version", func(t *testing.T) {
t.Parallel()
data := []byte("---\ntitle: \"Title\"\n---\nHello")
_, err := changelog.ParseEntry(data)
if err == nil {
t.Fatalf("expected error")
}
if !strings.Contains(err.Error(), "version") {
t.Fatalf("error %q does not mention version", err.Error())
}
})
}
+59
View File
@@ -0,0 +1,59 @@
package coderd
import "testing"
func TestNormalizeChangelogAssetPath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
want string
}{
{
name: "plain filename",
input: "2.30-hero.webp",
want: "2.30-hero.webp",
},
{
name: "assets prefix",
input: "assets/2.30-hero.webp",
want: "2.30-hero.webp",
},
{
name: "leading slash and assets prefix",
input: "/assets/2.30-hero.webp",
want: "2.30-hero.webp",
},
{
name: "whitespace",
input: " assets/2.30-hero.webp ",
want: "2.30-hero.webp",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := normalizeChangelogAssetPath(tc.input)
if got != tc.want {
t.Fatalf("normalizeChangelogAssetPath(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestChangelogImageURL(t *testing.T) {
t.Parallel()
got := changelogImageURL("assets/2.30-hero.webp")
want := "/api/v2/changelog/assets/2.30-hero.webp"
if got != want {
t.Fatalf("changelogImageURL() = %q, want %q", got, want)
}
if got := changelogImageURL(""); got != "" {
t.Fatalf("changelogImageURL(\"\") = %q, want empty string", got)
}
}
+12 -1
View File
@@ -51,6 +51,7 @@ import (
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/awsidentity"
"github.com/coder/coder/v2/coderd/boundaryusage"
"github.com/coder/coder/v2/coderd/changelog"
"github.com/coder/coder/v2/coderd/connectionlog"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database"
@@ -633,7 +634,7 @@ func New(options *Options) *API {
ProfileCollector: defaultProfileCollector{},
AISeatTracker: aiseats.Noop{},
}
api.ChangelogStore = changelog.NewStore()
api.WorkspaceAppsProvider = workspaceapps.NewDBTokenProvider(
ctx,
options.Logger.Named("workspaceapps"),
@@ -1903,6 +1904,15 @@ func New(options *Options) *API {
r.Post("/test", api.postTestNotification)
r.Post("/custom", api.postCustomNotification)
})
r.Route("/changelog", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/", api.listChangelogEntries)
r.Get("/unread", api.unreadChangelogNotification)
r.Route("/assets", func(r chi.Router) {
r.Get("/*", api.changelogAsset)
})
r.Get("/{version}", api.changelogEntryByVersion)
})
r.Route("/tailnet", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/", api.tailnetRPCConn)
@@ -2084,6 +2094,7 @@ type API struct {
metricsCache *metricscache.Cache
updateChecker *updatecheck.Checker
ChangelogStore *changelog.Store
WorkspaceAppsProvider workspaceapps.SignedTokenProvider
workspaceAppServer *workspaceapps.Server
agentProvider workspaceapps.AgentProvider
+2
View File
@@ -3846,6 +3846,8 @@ CREATE INDEX idx_workspace_app_statuses_workspace_id_created_at ON workspace_app
CREATE INDEX idx_workspace_builds_initiator_id ON workspace_builds USING btree (initiator_id);
CREATE INDEX notification_messages_changelog_version_user_idx ON notification_messages USING btree (notification_template_id, (((payload -> 'labels'::text) ->> 'version'::text)), user_id);
CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);
CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true);
+1
View File
@@ -15,6 +15,7 @@ const (
LockIDReconcilePrebuilds
LockIDReconcileSystemRoles
LockIDBoundaryUsageStats
LockIDChangelogBroadcast
)
// GenLockID generates a unique and consistent lock ID from a given string.
@@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = 'b02d53fd-477d-4a65-8d42-1b7e4b38f8c3';
@@ -0,0 +1,15 @@
INSERT INTO notification_templates (
id,
name,
title_template,
body_template,
"group",
actions
) VALUES (
'b02d53fd-477d-4a65-8d42-1b7e4b38f8c3',
'Changelog',
E'{{.Labels.version}}',
E'{{.Labels.summary}}',
'Changelog',
'[{"label":"View changelog","url":"/changelog/{{.Labels.version}}"}]'::jsonb
);
@@ -0,0 +1 @@
DROP INDEX IF EXISTS notification_messages_changelog_version_user_idx;
@@ -0,0 +1,6 @@
CREATE INDEX IF NOT EXISTS notification_messages_changelog_version_user_idx
ON notification_messages (
notification_template_id,
(payload -> 'labels' ->> 'version'),
user_id
);
+3
View File
@@ -54,6 +54,9 @@ var fallbackIcons = map[uuid.UUID]string{
notifications.TemplateTemplateDeleted: codersdk.InboxNotificationFallbackIconTemplate,
notifications.TemplateTemplateDeprecated: codersdk.InboxNotificationFallbackIconTemplate,
notifications.TemplateWorkspaceBuildsFailedReport: codersdk.InboxNotificationFallbackIconTemplate,
// changelog related notifications
notifications.TemplateChangelog: codersdk.InboxNotificationFallbackIconChangelog,
}
func ensureNotificationIcon(notif codersdk.InboxNotification) codersdk.InboxNotification {
+7 -2
View File
@@ -102,9 +102,14 @@ func (s *StoreEnqueuer) EnqueueWithData(ctx context.Context, userID, templateID
}
methods := []database.NotificationMethod{}
if metadata.CustomMethod.Valid {
switch {
case templateID == TemplateChangelog:
if s.inboxEnabled {
methods = append(methods, database.NotificationMethodInbox)
}
case metadata.CustomMethod.Valid:
methods = append(methods, metadata.CustomMethod.NotificationMethod)
} else if s.defaultEnabled {
case s.defaultEnabled:
methods = append(methods, s.defaultMethod)
}
+5
View File
@@ -62,3 +62,8 @@ var (
TemplateTaskPaused = uuid.MustParse("2a74f3d3-ab09-4123-a4a5-ca238f4f65a1")
TemplateTaskResumed = uuid.MustParse("843ee9c3-a8fb-4846-afa9-977bec578649")
)
// Changelog-related events.
var (
TemplateChangelog = uuid.MustParse("b02d53fd-477d-4a65-8d42-1b7e4b38f8c3")
)
@@ -1301,6 +1301,20 @@ func TestNotificationTemplates_Golden(t *testing.T) {
Data: map[string]any{},
},
},
{
name: "TemplateChangelog",
id: notifications.TemplateChangelog,
payload: types.MessagePayload{
UserName: "Bobby",
UserEmail: "bobby@coder.com",
UserUsername: "bobby",
Labels: map[string]string{
"version": "2.30",
"summary": "Highlights of this release.",
},
Data: map[string]any{},
},
},
{
name: "TemplateTaskPaused",
id: notifications.TemplateTaskPaused,
@@ -1354,6 +1368,9 @@ func TestNotificationTemplates_Golden(t *testing.T) {
t.Run("smtp", func(t *testing.T) {
t.Parallel()
if tc.id == notifications.TemplateChangelog {
t.Skip("changelog notifications are inbox-only")
}
// Spin up the DB
db, logger, user := func() (*database.Store, *slog.Logger, *codersdk.User) {
@@ -1537,6 +1554,9 @@ func TestNotificationTemplates_Golden(t *testing.T) {
t.Run("webhook", func(t *testing.T) {
t.Parallel()
if tc.id == notifications.TemplateChangelog {
t.Skip("changelog notifications are inbox-only")
}
// Spin up the DB
db, logger, user := func() (*database.Store, *slog.Logger, *codersdk.User) {
@@ -0,0 +1,76 @@
From: system@coder.com
To: bobby@coder.com
Subject: 2.30
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
Date: Fri, 11 Oct 2024 09:03:06 +0000
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
MIME-Version: 1.0
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=UTF-8
Hi Bobby,
Highlights of this release.
View changelog: /changelog/2.30
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=UTF-8
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>2.30</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
2.30
</h1>
<div style=3D"line-height: 1.5;">
<p>Hi Bobby,</p>
<p>Highlights of this release.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"/changelog/2.30" style=3D"display: inline-block; padding=
: 13px 24px; background-color: #020617; color: #f8fafc; text-decoration: no=
ne; border-radius: 8px; margin: 0 4px;">
View changelog
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3Db02=
d53fd-477d-4a65-8d42-1b7e4b38f8c3" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--
@@ -0,0 +1,29 @@
{
"_version": "1.1",
"msg_id": "00000000-0000-0000-0000-000000000000",
"payload": {
"_version": "1.2",
"notification_name": "Changelog",
"notification_template_id": "00000000-0000-0000-0000-000000000000",
"user_id": "00000000-0000-0000-0000-000000000000",
"user_email": "bobby@coder.com",
"user_name": "Bobby",
"user_username": "bobby",
"actions": [
{
"label": "View changelog",
"url": "/changelog/2.30"
}
],
"labels": {
"summary": "Highlights of this release.",
"version": "2.30"
},
"data": {},
"targets": null
},
"title": "2.30",
"title_markdown": "2.30",
"body": "Highlights of this release.",
"body_markdown": "Highlights of this release."
}
+70
View File
@@ -0,0 +1,70 @@
package codersdk
import (
"context"
"encoding/json"
"net/http"
"net/url"
)
type ChangelogEntry struct {
Version string `json:"version"`
Title string `json:"title"`
Date string `json:"date"`
Summary string `json:"summary"`
ImageURL string `json:"image_url"`
Content string `json:"content,omitempty"`
}
type ListChangelogEntriesResponse struct {
Entries []ChangelogEntry `json:"entries"`
}
type UnreadChangelogNotificationResponse struct {
Notification *InboxNotification `json:"notification"`
}
func (c *Client) ListChangelogEntries(ctx context.Context) (ListChangelogEntriesResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/changelog", nil)
if err != nil {
return ListChangelogEntriesResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ListChangelogEntriesResponse{}, ReadBodyAsError(res)
}
var resp ListChangelogEntriesResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
func (c *Client) GetChangelogEntry(ctx context.Context, version string) (ChangelogEntry, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/changelog/"+url.PathEscape(version), nil)
if err != nil {
return ChangelogEntry{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ChangelogEntry{}, ReadBodyAsError(res)
}
var resp ChangelogEntry
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
func (c *Client) UnreadChangelogNotification(ctx context.Context) (UnreadChangelogNotificationResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/changelog/unread", nil)
if err != nil {
return UnreadChangelogNotificationResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return UnreadChangelogNotificationResponse{}, ReadBodyAsError(res)
}
var resp UnreadChangelogNotificationResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
+1
View File
@@ -14,6 +14,7 @@ const (
InboxNotificationFallbackIconWorkspace = "DEFAULT_ICON_WORKSPACE"
InboxNotificationFallbackIconAccount = "DEFAULT_ICON_ACCOUNT"
InboxNotificationFallbackIconTemplate = "DEFAULT_ICON_TEMPLATE"
InboxNotificationFallbackIconChangelog = "DEFAULT_ICON_CHANGELOG"
InboxNotificationFallbackIconOther = "DEFAULT_ICON_OTHER"
)
+4
View File
@@ -1457,6 +1457,10 @@
"title": "Builds",
"path": "./reference/api/builds.md"
},
{
"title": "Changelog",
"path": "./reference/api/changelog.md"
},
{
"title": "Chats",
"path": "./reference/api/chats.md"
+158
View File
@@ -0,0 +1,158 @@
# Changelog
## List changelog entries
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/changelog \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /changelog`
### Example responses
> 200 Response
```json
{
"entries": [
{
"content": "string",
"date": "string",
"image_url": "string",
"summary": "string",
"title": "string",
"version": "string"
}
]
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|------------------------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ListChangelogEntriesResponse](schemas.md#codersdklistchangelogentriesresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get changelog asset
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/changelog/assets/{path} \
-H 'Coder-Session-Token: API_KEY'
```
`GET /changelog/assets/{path}`
### Parameters
| Name | In | Type | Required | Description |
|--------|------|--------|----------|-------------|
| `path` | path | string | true | Asset path |
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|--------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get unread changelog notification
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/changelog/unread \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /changelog/unread`
### Example responses
> 200 Response
```json
{
"notification": {
"actions": [
{
"label": "string",
"url": "string"
}
],
"content": "string",
"created_at": "2019-08-24T14:15:22Z",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"read_at": "string",
"targets": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"title": "string",
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5"
}
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UnreadChangelogNotificationResponse](schemas.md#codersdkunreadchangelognotificationresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get changelog entry
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/changelog/{version} \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /changelog/{version}`
### Parameters
| Name | In | Type | Required | Description |
|-----------|------|--------|----------|-------------|
| `version` | path | string | true | Version |
### Example responses
> 200 Response
```json
{
"content": "string",
"date": "string",
"image_url": "string",
"summary": "string",
"title": "string",
"version": "string"
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.ChangelogEntry](schemas.md#codersdkchangelogentry) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+79
View File
@@ -2009,6 +2009,30 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `one_time_passcode` | string | true | | |
| `password` | string | true | | |
## codersdk.ChangelogEntry
```json
{
"content": "string",
"date": "string",
"image_url": "string",
"summary": "string",
"title": "string",
"version": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-------------|--------|----------|--------------|-------------|
| `content` | string | false | | |
| `date` | string | false | | |
| `image_url` | string | false | | |
| `summary` | string | false | | |
| `title` | string | false | | |
| `version` | string | false | | |
## codersdk.ChatConfig
```json
@@ -5327,6 +5351,29 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
| `icon` | `bug`, `chat`, `docs`, `star` |
| `location` | `dropdown`, `navbar` |
## codersdk.ListChangelogEntriesResponse
```json
{
"entries": [
{
"content": "string",
"date": "string",
"image_url": "string",
"summary": "string",
"title": "string",
"version": "string"
}
]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|-----------|-------------------------------------------------------------|----------|--------------|-------------|
| `entries` | array of [codersdk.ChangelogEntry](#codersdkchangelogentry) | false | | |
## codersdk.ListInboxNotificationsResponse
```json
@@ -10254,6 +10301,38 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
| `p50` | integer | false | | |
| `p95` | integer | false | | |
## codersdk.UnreadChangelogNotificationResponse
```json
{
"notification": {
"actions": [
{
"label": "string",
"url": "string"
}
],
"content": "string",
"created_at": "2019-08-24T14:15:22Z",
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"read_at": "string",
"targets": [
"497f6eca-6276-4993-bfeb-53cbbbba6f08"
],
"template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc",
"title": "string",
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5"
}
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|----------------|----------------------------------------------------------|----------|--------------|-------------|
| `notification` | [codersdk.InboxNotification](#codersdkinboxnotification) | false | | |
## codersdk.UpdateActiveTemplateVersion
```json
+14
View File
@@ -11,6 +11,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/codersdk"
)
@@ -63,6 +64,19 @@ func (api *API) updateNotificationTemplateMethod(rw http.ResponseWriter, r *http
return
}
if template.ID == notifications.TemplateChangelog {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid request to update notification template method",
Validations: []codersdk.ValidationError{
{
Field: "method",
Detail: "changelog notifications are inbox-only and cannot be changed",
},
},
})
return
}
if template.Method == nm {
httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{
Message: "Notification template method unchanged.",
+28
View File
@@ -104,6 +104,34 @@ func TestUpdateNotificationTemplateMethod(t *testing.T) {
require.Equal(t, fmt.Sprintf("%q is not a valid method; smtp, webhook, inbox are the available options", method), sdkError.Response.Validations[0].Detail)
})
t.Run("Changelog method is immutable", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitSuperLong)
api, _ := coderdenttest.New(t, createOpts(t))
template, err := getTemplateByID(t, ctx, api, notifications.TemplateChangelog)
require.NoError(t, err)
require.NotNil(t, template)
originalMethod := template.Method
err = api.UpdateNotificationTemplateMethod(ctx, notifications.TemplateChangelog, string(database.NotificationMethodWebhook))
var sdkError *codersdk.Error
require.Error(t, err)
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
require.Equal(t, http.StatusBadRequest, sdkError.StatusCode())
require.Equal(t, "Invalid request to update notification template method", sdkError.Response.Message)
require.Len(t, sdkError.Response.Validations, 1)
require.Equal(t, "method", sdkError.Response.Validations[0].Field)
require.Equal(t, "changelog notifications are inbox-only and cannot be changed", sdkError.Response.Validations[0].Detail)
template, err = getTemplateByID(t, ctx, api, notifications.TemplateChangelog)
require.NoError(t, err)
require.NotNil(t, template)
require.Equal(t, originalMethod, template.Method)
})
t.Run("Not modified", func(t *testing.T) {
t.Parallel()
+34
View File
@@ -2892,6 +2892,40 @@ class ApiMethods {
await this.axios.put<void>("/api/v2/notifications/inbox/mark-all-as-read");
};
getChangelogEntries =
async (): Promise<TypesGen.ListChangelogEntriesResponse> => {
const res =
await this.axios.get<TypesGen.ListChangelogEntriesResponse>(
"/api/v2/changelog",
);
return res.data;
};
getChangelogEntry = async (
version: string,
): Promise<TypesGen.ChangelogEntry> => {
const res = await this.axios.get<TypesGen.ChangelogEntry>(
`/api/v2/changelog/${encodeURIComponent(version)}`,
);
return res.data;
};
getChangelogAsset = async (assetURL: string): Promise<Blob> => {
const res = await this.axios.get(assetURL, {
responseType: "blob",
});
return res.data;
};
getUnreadChangelogNotification =
async (): Promise<TypesGen.UnreadChangelogNotificationResponse> => {
const res =
await this.axios.get<TypesGen.UnreadChangelogNotificationResponse>(
"/api/v2/changelog/unread",
);
return res.data;
};
createTask = async (
user: string,
req: TypesGen.CreateTaskRequest,
+23
View File
@@ -1177,6 +1177,16 @@ export interface ChangePasswordWithOneTimePasscodeRequest {
readonly one_time_passcode: string;
}
// From codersdk/changelog.go
export interface ChangelogEntry {
readonly version: string;
readonly title: string;
readonly date: string;
readonly summary: string;
readonly image_url: string;
readonly content?: string;
}
// From codersdk/chats.go
/**
* Chat represents a chat session with an AI agent.
@@ -3854,6 +3864,9 @@ export interface InboxNotificationAction {
// From codersdk/inboxnotification.go
export const InboxNotificationFallbackIconAccount = "DEFAULT_ICON_ACCOUNT";
// From codersdk/inboxnotification.go
export const InboxNotificationFallbackIconChangelog = "DEFAULT_ICON_CHANGELOG";
// From codersdk/inboxnotification.go
export const InboxNotificationFallbackIconOther = "DEFAULT_ICON_OTHER";
@@ -3944,6 +3957,11 @@ export interface LinkConfig {
readonly location?: string;
}
// From codersdk/changelog.go
export interface ListChangelogEntriesResponse {
readonly entries: readonly ChangelogEntry[];
}
// From codersdk/chats.go
/**
* ListChatsOptions are optional parameters for ListChats.
@@ -7137,6 +7155,11 @@ export interface TransitionStats {
readonly P95: number | null;
}
// From codersdk/changelog.go
export interface UnreadChangelogNotificationResponse {
readonly notification: InboxNotification | null;
}
// From codersdk/templates.go
export interface UpdateActiveTemplateVersion {
readonly id: string;
@@ -0,0 +1,114 @@
import { type FC, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { API } from "#/api/api";
import { Badge } from "#/components/Badge/Badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "#/components/Dialog/Dialog";
import { Loader } from "#/components/Loader/Loader";
import { Markdown } from "#/components/Markdown/Markdown";
interface ChangelogDialogProps {
version: string | null;
onClose: () => void;
}
export const ChangelogDialog: FC<ChangelogDialogProps> = ({
version,
onClose,
}) => {
const { data, error, isLoading } = useQuery({
queryKey: ["changelog", version],
queryFn: () => API.getChangelogEntry(version!),
enabled: Boolean(version),
});
const [imageObjectURL, setImageObjectURL] = useState<string | null>(null);
useEffect(() => {
if (!data?.image_url) {
setImageObjectURL(null);
return;
}
let cancelled = false;
let objectURL: string | null = null;
void (async () => {
try {
const blob = await API.getChangelogAsset(data.image_url);
if (cancelled) {
return;
}
objectURL = URL.createObjectURL(blob);
setImageObjectURL(objectURL);
} catch {
if (!cancelled) {
setImageObjectURL(null);
}
}
})();
return () => {
cancelled = true;
if (objectURL) {
URL.revokeObjectURL(objectURL);
}
};
}, [data?.image_url]);
return (
<Dialog open={Boolean(version)} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
{isLoading ? (
<Loader />
) : error ? (
<div className="text-sm text-content-secondary">
Unable to load changelog.
</div>
) : data ? (
<>
<DialogHeader>
<div className="flex items-center gap-2 mb-2">
<Badge
variant="info"
size="xs"
border="solid"
className="font-semibold"
>
Changelog
</Badge>
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-surface-secondary text-content-secondary border border-default">
{data.version}
</span>
<span className="text-xs text-content-secondary">
{data.date}
</span>
</div>
<DialogTitle>{data.title}</DialogTitle>
{data.summary && (
<DialogDescription>{data.summary}</DialogDescription>
)}
</DialogHeader>
{data.image_url && (
<img
src={imageObjectURL ?? data.image_url}
alt={`${data.title} hero`}
className="w-full rounded-lg"
/>
)}
<Markdown className="prose prose-sm dark:prose-invert max-w-none">
{data.content ?? ""}
</Markdown>
</>
) : null}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,47 @@
import {
createContext,
type FC,
type PropsWithChildren,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import { ChangelogDialog } from "./ChangelogDialog";
interface ChangelogContextValue {
openChangelog: (version: string) => void;
}
const ChangelogContext = createContext<ChangelogContextValue | undefined>(
undefined,
);
export const useChangelog = (): ChangelogContextValue => {
const ctx = useContext(ChangelogContext);
if (!ctx) {
throw new Error("useChangelog must be used within ChangelogProvider");
}
return ctx;
};
export const ChangelogProvider: FC<PropsWithChildren> = ({ children }) => {
const [version, setVersion] = useState<string | null>(null);
const openChangelog = useCallback((v: string) => {
setVersion(v);
}, []);
const closeChangelog = useCallback(() => {
setVersion(null);
}, []);
const value = useMemo(() => ({ openChangelog }), [openChangelog]);
return (
<ChangelogContext.Provider value={value}>
{children}
<ChangelogDialog version={version} onClose={closeChangelog} />
</ChangelogContext.Provider>
);
};
+2
View File
@@ -0,0 +1,2 @@
export { ChangelogProvider, useChangelog } from "./ChangelogProvider";
export { useChangelogToast } from "./useChangelogToast";
@@ -0,0 +1,121 @@
import { useEffect } from "react";
import { useQueryClient } from "react-query";
import { toast } from "sonner";
import { API } from "#/api/api";
import { useChangelog } from "./ChangelogProvider";
const CHANGELOG_TOAST_KEY = "changelog-toast-last-seen";
const changelogToastStorageKey = (userID: string) =>
`${CHANGELOG_TOAST_KEY}:${userID}`;
const unreadChangelogNotificationQueryKey = [
"changelog",
"unreadNotification",
] as const;
export const useChangelogToast = () => {
const { openChangelog } = useChangelog();
const queryClient = useQueryClient();
useEffect(() => {
if (typeof localStorage === "undefined") {
return;
}
let cancelled = false;
let settled = false;
const timers: number[] = [];
const pollDelaysMs = [0, 15000, 60000] as const;
const maxPollAttempts = 12;
let pollAttempt = 0;
const checkForUnread = async () => {
if (cancelled || settled) {
return;
}
try {
const { notification } = await queryClient.fetchQuery({
queryKey: unreadChangelogNotificationQueryKey,
queryFn: API.getUnreadChangelogNotification,
staleTime: 0,
});
if (cancelled || settled || !notification) {
return;
}
const version = notification.title;
const toastStorageKey = changelogToastStorageKey(notification.user_id);
const lastSeen = localStorage.getItem(toastStorageKey);
if (lastSeen === version) {
settled = true;
return;
}
// Mark as seen immediately so it only shows once.
localStorage.setItem(toastStorageKey, version);
settled = true;
toast(`What's new in Coder ${version}`, {
description: notification.content,
duration: 10000,
action: {
label: "View changelog",
onClick: () => {
openChangelog(version);
void (async () => {
try {
await API.updateInboxNotificationReadStatus(notification.id, {
is_read: true,
});
} catch {
// Silently ignore — not critical.
} finally {
void queryClient.invalidateQueries({
queryKey: ["notifications"],
});
void queryClient.invalidateQueries({
queryKey: unreadChangelogNotificationQueryKey,
});
}
})();
},
},
});
} catch {
// Silently ignore — not critical.
}
};
// BroadcastChangelog runs asynchronously at startup and may take longer
// on larger deployments. Poll until a changelog notification appears,
// but cap retries so the dashboard does not keep background polling
// indefinitely when there is nothing new to show.
const scheduleNextPoll = () => {
if (cancelled || settled || pollAttempt >= maxPollAttempts) {
return;
}
const delayMs =
pollDelaysMs[Math.min(pollAttempt, pollDelaysMs.length - 1)];
pollAttempt++;
timers.push(
window.setTimeout(() => {
void checkForUnread().finally(() => {
scheduleNextPoll();
});
}, delayMs),
);
};
scheduleNextPoll();
return () => {
cancelled = true;
for (const timer of timers) {
window.clearTimeout(timer);
}
};
}, [openChangelog, queryClient]);
};
@@ -6,6 +6,7 @@ import { Outlet } from "react-router";
import { Button } from "#/components/Button/Button";
import { Loader } from "#/components/Loader/Loader";
import { useAuthenticated } from "#/hooks/useAuthenticated";
import { ChangelogProvider, useChangelogToast } from "#/modules/changelog";
import { AnnouncementBanners } from "#/modules/dashboard/AnnouncementBanners/AnnouncementBanners";
import { LicenseBanner } from "#/modules/dashboard/LicenseBanner/LicenseBanner";
import { cn } from "#/utils/cn";
@@ -14,13 +15,19 @@ import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner";
import { Navbar } from "./Navbar/Navbar";
import { useUpdateCheck } from "./useUpdateCheck";
const ChangelogToast: FC = () => {
useChangelogToast();
return null;
};
export const DashboardLayout: FC = () => {
const { permissions } = useAuthenticated();
const updateCheck = useUpdateCheck(permissions.viewDeploymentConfig);
const canViewDeployment = Boolean(permissions.viewDeploymentConfig);
return (
<>
<ChangelogProvider>
<ChangelogToast />
{canViewDeployment && <LicenseBanner />}
<AnnouncementBanners />
@@ -105,7 +112,7 @@ export const DashboardLayout: FC = () => {
}
/>
</div>
</>
</ChangelogProvider>
);
};
@@ -2,6 +2,7 @@ import {
InfoIcon,
LaptopIcon,
LayoutTemplateIcon,
RocketIcon,
UserIcon,
} from "lucide-react";
import type React from "react";
@@ -14,10 +15,13 @@ import {
} from "#/api/typesGenerated";
import { Avatar } from "#/components/Avatar/Avatar";
const CHANGELOG_ICON = "DEFAULT_ICON_CHANGELOG";
const InboxNotificationFallbackIcons = [
InboxNotificationFallbackIconAccount,
InboxNotificationFallbackIconWorkspace,
InboxNotificationFallbackIconTemplate,
CHANGELOG_ICON,
InboxNotificationFallbackIconOther,
] as const;
@@ -28,6 +32,7 @@ const fallbackIcons: Record<InboxNotificationFallbackIcon, React.ReactNode> = {
DEFAULT_ICON_WORKSPACE: <LaptopIcon />,
DEFAULT_ICON_ACCOUNT: <UserIcon />,
DEFAULT_ICON_TEMPLATE: <LayoutTemplateIcon />,
DEFAULT_ICON_CHANGELOG: <RocketIcon />,
DEFAULT_ICON_OTHER: <InfoIcon />,
};
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { ChangelogProvider } from "#/modules/changelog";
import { MockNotification } from "#/testHelpers/entities";
import { daysAgo } from "#/utils/time";
import { InboxItem } from "./InboxItem";
@@ -9,9 +10,11 @@ const meta: Meta<typeof InboxItem> = {
component: InboxItem,
render: (args) => {
return (
<div className="max-w-[460px] border-solid border-border rounded">
<InboxItem {...args} />
</div>
<ChangelogProvider>
<div className="max-w-[460px] border-solid border-border rounded">
<InboxItem {...args} />
</div>
</ChangelogProvider>
);
},
};
@@ -5,9 +5,12 @@ import { Link as RouterLink } from "react-router";
import type { InboxNotification } from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Link } from "#/components/Link/Link";
import { useChangelog } from "#/modules/changelog";
import { relativeTime } from "#/utils/time";
import { InboxAvatar } from "./InboxAvatar";
const CHANGELOG_TEMPLATE_ID = "b02d53fd-477d-4a65-8d42-1b7e4b38f8c3";
type InboxItemProps = {
notification: InboxNotification;
onMarkNotificationAsRead: (notificationId: string) => void;
@@ -17,6 +20,9 @@ export const InboxItem: FC<InboxItemProps> = ({
notification,
onMarkNotificationAsRead,
}) => {
const isChangelog = notification.template_id === CHANGELOG_TEMPLATE_ID;
const { openChangelog } = useChangelog();
return (
<div
className="flex items-stretch gap-3 p-3 group"
@@ -28,18 +34,46 @@ export const InboxItem: FC<InboxItemProps> = ({
</div>
<div className="flex flex-col gap-3 flex-1">
<Markdown
className="text-content-secondary prose-sm font-medium [overflow-wrap:anywhere]"
components={{
a: ({ node, ...props }) => {
return <Link {...props} />;
},
}}
>
{notification.content}
</Markdown>
<div>
{isChangelog && (
<div className="flex items-center gap-2 mb-1">
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold bg-violet-100 text-violet-700 dark:bg-violet-500/20 dark:text-violet-400 border border-violet-300 dark:border-violet-500/30">
Changelog
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-surface-secondary text-content-secondary border border-default">
{notification.title}
</span>
</div>
)}
<Markdown
className="text-content-secondary prose-sm font-medium [overflow-wrap:anywhere]"
components={{
a: ({ node, ...props }) => {
return <Link {...props} />;
},
}}
>
{notification.content}
</Markdown>
</div>
<div className="flex items-center gap-1">
{notification.actions.map((action) => {
if (isChangelog) {
return (
<Button
variant="outline"
size="sm"
key={action.label}
onClick={() => {
openChangelog(notification.title);
onMarkNotificationAsRead(notification.id);
}}
>
{action.label}
</Button>
);
}
return (
<Button variant="outline" size="sm" key={action.label} asChild>
<RouterLink
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { ChangelogProvider } from "#/modules/changelog";
import { MockNotifications } from "#/testHelpers/entities";
import { InboxPopover } from "./InboxPopover";
@@ -11,11 +12,13 @@ const meta: Meta<typeof InboxPopover> = {
},
render: (args) => {
return (
<div className="w-full max-w-screen-xl p-6 h-[720px]">
<header className="flex justify-end">
<InboxPopover {...args} />
</header>
</div>
<ChangelogProvider>
<div className="w-full max-w-screen-xl p-6 h-[720px]">
<header className="flex justify-end">
<InboxPopover {...args} />
</header>
</div>
</ChangelogProvider>
);
},
};
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { ChangelogProvider } from "#/modules/changelog";
import { MockNotifications, mockApiError } from "#/testHelpers/entities";
import { withToaster } from "#/testHelpers/storybook";
import { NotificationsInbox } from "./NotificationsInbox";
@@ -9,11 +10,13 @@ const meta: Meta<typeof NotificationsInbox> = {
component: NotificationsInbox,
render: (args) => {
return (
<div className="w-full max-w-screen-xl p-6 h-[720px]">
<header className="flex justify-end">
<NotificationsInbox {...args} />
</header>
</div>
<ChangelogProvider>
<div className="w-full max-w-screen-xl p-6 h-[720px]">
<header className="flex justify-end">
<NotificationsInbox {...args} />
</header>
</div>
</ChangelogProvider>
);
},
};