Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8ff538144 | |||
| a0e1dd546d | |||
| 960bb5562e | |||
| 1fd0d5d8ca | |||
| f1ec33f90b | |||
| d8edde4800 | |||
| cb1390e6f1 | |||
| 15b19a6b5e | |||
| 35e534e1c2 | |||
| 9e060370bc | |||
| 8fa00d9066 | |||
| 308b62eceb | |||
| 18f8f46de3 | |||
| 8febb74228 | |||
| 6ac2ddfb88 | |||
| 740c644190 | |||
| f84241b5b3 | |||
| 804c2e8780 | |||
| 8a4a7bf214 | |||
| f66bc36b87 | |||
| 8fffe8232f | |||
| 83771a5c12 | |||
| c54f7c9863 | |||
| 21a0fe4941 | |||
| e19e14d8cb | |||
| dcb9666dcd | |||
| 836c372db6 | |||
| 8fb44b0ce5 | |||
| 191f25db4c | |||
| fd107e4f84 | |||
| baf105f0f6 | |||
| 57b7302a49 | |||
| d8658edd6e | |||
| f56c017ac0 | |||
| adac64f250 | |||
| 273944fad6 | |||
| d279b1039d | |||
| 33642c10f2 | |||
| 6d33befdc3 | |||
| 54dbf3fff0 | |||
| a59ef7bf94 | |||
| 41f91e31e6 | |||
| 1b3ba81346 | |||
| c4cee8d002 | |||
| 978b86a601 |
@@ -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)
|
||||
|
||||
Generated
+157
@@ -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": [
|
||||
|
||||
Generated
+141
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package changelog
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed entries/*.md assets/*
|
||||
var FS embed.FS
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+2
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+76
@@ -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>© 2024 Coder. All rights reserved - <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--
|
||||
+29
@@ -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."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+158
@@ -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).
|
||||
Generated
+79
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+23
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user