Enhancement webhook usability (#807)

* feat: secret copier

* feat: improved form

* fix: quotes

* wip: improved flow

* feat: health check logging

* fix: page design

* fix: hard delete, no upsert

* fix: reset modal state

* fix: empty text

* fix: worker state

* fix: update only token

* fix: dont delete name

* fix: logs component

* fix: sort order

* chore: build

* fix: webhook worker cleanup

* chore: squash migrations

* Update api-contracts/openapi/paths/webhook-worker/webhook-worker.yaml

Co-authored-by: abelanger5 <belanger@sas.upenn.edu>

* chore: rename

* fix: wrong query

---------

Co-authored-by: abelanger5 <belanger@sas.upenn.edu>
This commit is contained in:
Gabe Ruttner
2024-08-23 07:09:09 -07:00
committed by GitHub
parent 2b9121d295
commit 53be615d5f
45 changed files with 6448 additions and 330 deletions

View File

@@ -234,6 +234,12 @@ WorkflowMetrics:
$ref: "./workflow.yaml#/WorkflowMetrics"
WebhookWorker:
$ref: "./webhook_worker.yaml#/WebhookWorker"
WebhookWorkerRequestMethod:
$ref: "./webhook_worker.yaml#/WebhookWorkerRequestMethod"
WebhookWorkerRequest:
$ref: "./webhook_worker.yaml#/WebhookWorkerRequest"
WebhookWorkerRequestListResponse:
$ref: "./webhook_worker.yaml#/WebhookWorkerRequestListResponse"
WebhookWorkerCreated:
$ref: "./webhook_worker.yaml#/WebhookWorkerCreated"
WebhookWorkerCreateRequest:

View File

@@ -14,6 +14,39 @@ WebhookWorker:
- url
type: object
WebhookWorkerRequestMethod:
enum:
- GET
- POST
- PUT
WebhookWorkerRequest:
properties:
created_at:
type: string
format: date-time
description: The date and time the request was created.
method:
$ref: "#/WebhookWorkerRequestMethod"
description: The HTTP method used for the request.
statusCode:
type: integer
description: The HTTP status code of the response.
required:
- created_at
- method
- statusCode
type: object
WebhookWorkerRequestListResponse:
properties:
requests:
type: array
items:
$ref: "#/WebhookWorkerRequest"
description: The list of webhook requests.
type: object
WebhookWorkerCreated:
properties:
metadata:

View File

@@ -140,6 +140,10 @@ Worker:
webhookUrl:
type: string
description: The webhook URL for the worker.
webhookId:
type: string
description: The webhook ID for the worker.
format: uuid
required:
- metadata
- name

View File

@@ -148,5 +148,7 @@ paths:
$ref: "./paths/webhook-worker/webhook-worker.yaml#/webhookworkers"
/api/v1/webhook-workers/{webhook}:
$ref: "./paths/webhook-worker/webhook-worker.yaml#/webhookworker"
/api/v1/webhook-workers/{webhook}/requests:
$ref: "./paths/webhook-worker/webhook-worker.yaml#/webhookworkerRequests"
/api/v1/workflow-runs/{workflow-run}/input:
$ref: "./paths/workflow-run/workflow-run.yaml#/getWorkflowRunInput"

View File

@@ -121,3 +121,45 @@ webhookworker:
schema:
$ref: "../../components/schemas/_index.yaml#/APIErrors"
description: Method not allowed
webhookworkerRequests:
get:
description: Lists all requests for a webhook
summary: List webhook requests
operationId: webhook-requests:list
x-resources: ["tenant", "webhook"]
parameters:
- description: The webhook id
in: path
name: webhook
required: true
schema:
type: string
format: uuid
minLength: 36
maxLength: 36
responses:
"200":
content:
application/json:
schema:
$ref: "../../components/schemas/_index.yaml#/WebhookWorkerRequestListResponse"
description: The list of webhook requests
"400":
content:
application/json:
schema:
$ref: "../../components/schemas/_index.yaml#/APIErrors"
description: A malformed or bad request
"401":
content:
application/json:
schema:
$ref: "../../components/schemas/_index.yaml#/APIErrors"
description: Unauthorized
"405":
content:
application/json:
schema:
$ref: "../../components/schemas/_index.yaml#/APIErrors"
description: Method not allowed

View File

@@ -1,8 +1,11 @@
package webhookworker
import (
"errors"
"github.com/labstack/echo/v4"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/apierrors"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/transformers"
"github.com/hatchet-dev/hatchet/pkg/random"
@@ -29,13 +32,20 @@ func (i *WebhookWorkersService) WebhookCreate(ctx echo.Context, request gen.Webh
return nil, err
}
ww, err := i.config.EngineRepository.WebhookWorker().UpsertWebhookWorker(ctx.Request().Context(), &repository.UpsertWebhookWorkerOpts{
ww, err := i.config.EngineRepository.WebhookWorker().CreateWebhookWorker(ctx.Request().Context(), &repository.CreateWebhookWorkerOpts{
TenantId: tenant.ID,
Name: request.Body.Name,
URL: request.Body.Url,
Secret: encSecret,
Deleted: repository.BoolPtr(false),
})
if errors.Is(err, repository.ErrDuplicateKey) {
return gen.WebhookCreate400JSONResponse(
apierrors.NewAPIErrors("A webhook with the same url already exists, please delete it and try again.", "url"),
), nil
}
if err != nil {
return nil, err
}

View File

@@ -11,7 +11,7 @@ func (i *WebhookWorkersService) WebhookDelete(ctx echo.Context, request gen.Webh
tenant := ctx.Get("tenant").(*db.TenantModel)
webhook := ctx.Get("webhook").(*db.WebhookWorkerModel)
err := i.config.EngineRepository.WebhookWorker().DeleteWebhookWorker(ctx.Request().Context(), webhook.ID, tenant.ID)
err := i.config.EngineRepository.WebhookWorker().SoftDeleteWebhookWorker(ctx.Request().Context(), webhook.ID, tenant.ID)
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,34 @@
package webhookworker
import (
"context"
"time"
"github.com/labstack/echo/v4"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/transformers"
)
func (i *WebhookWorkersService) WebhookRequestsList(ctx echo.Context, request gen.WebhookRequestsListRequestObject) (gen.WebhookRequestsListResponseObject, error) {
dbCtx, cancel := context.WithTimeout(ctx.Request().Context(), 30*time.Second)
defer cancel()
requests, err := i.config.EngineRepository.WebhookWorker().ListWebhookWorkerRequests(dbCtx, request.Webhook.String())
if err != nil {
return nil, err
}
rows := make([]gen.WebhookWorkerRequest, len(requests))
for i := range requests {
rows[i] = *transformers.ToWebhookWorkerRequest(requests[i])
}
return gen.WebhookRequestsList200JSONResponse(
gen.WebhookWorkerRequestListResponse{
Requests: &rows,
},
), nil
}

View File

@@ -27,6 +27,15 @@ func (t *WorkerService) WorkerGet(ctx echo.Context, request gen.WorkerGetRequest
return nil, err
}
actions, err := t.config.APIRepository.Worker().GetWorkerActionsByWorkerId(
sqlchelpers.UUIDToStr(worker.Worker.TenantId),
sqlchelpers.UUIDToStr(worker.Worker.ID),
)
if err != nil {
return nil, err
}
respStepRuns := make([]gen.RecentStepRuns, len(recent))
for i := range recent {
@@ -39,7 +48,9 @@ func (t *WorkerService) WorkerGet(ctx echo.Context, request gen.WorkerGetRequest
respStepRuns[i] = *genStepRun
}
workerResp := *transformers.ToWorkerSqlc(&worker.Worker, nil, &worker.WebhookUrl.String)
slots := int(worker.FilledSlots)
workerResp := *transformers.ToWorkerSqlc(&worker.Worker, &slots, &worker.WebhookUrl.String, actions)
workerResp.RecentStepRuns = &respStepRuns
workerResp.Slots = transformers.ToSlotState(slotState)

View File

@@ -30,7 +30,7 @@ func (t *WorkerService) WorkerList(ctx echo.Context, request gen.WorkerListReque
workerCp := worker
slots := int(worker.Slots)
rows[i] = *transformers.ToWorkerSqlc(&workerCp.Worker, &slots, &workerCp.WebhookUrl.String)
rows[i] = *transformers.ToWorkerSqlc(&workerCp.Worker, &slots, &workerCp.WebhookUrl.String, nil)
}
return gen.WorkerList200JSONResponse(

View File

@@ -35,5 +35,5 @@ func (t *WorkerService) WorkerUpdate(ctx echo.Context, request gen.WorkerUpdateR
return nil, err
}
return gen.WorkerUpdate200JSONResponse(*transformers.ToWorkerSqlc(updatedWorker, nil, nil)), nil
return gen.WorkerUpdate200JSONResponse(*transformers.ToWorkerSqlc(updatedWorker, nil, nil, nil)), nil
}

View File

@@ -966,6 +966,25 @@ type WebhookWorkerListResponse struct {
Rows *[]WebhookWorker `json:"rows,omitempty"`
}
// WebhookWorkerRequest defines model for WebhookWorkerRequest.
type WebhookWorkerRequest struct {
// CreatedAt The date and time the request was created.
CreatedAt time.Time `json:"created_at"`
Method WebhookWorkerRequestMethod `json:"method"`
// StatusCode The HTTP status code of the response.
StatusCode int `json:"statusCode"`
}
// WebhookWorkerRequestListResponse defines model for WebhookWorkerRequestListResponse.
type WebhookWorkerRequestListResponse struct {
// Requests The list of webhook requests.
Requests *[]WebhookWorkerRequest `json:"requests,omitempty"`
}
// WebhookWorkerRequestMethod defines model for WebhookWorkerRequestMethod.
type WebhookWorkerRequestMethod = interface{}
// Worker defines model for Worker.
type Worker struct {
// Actions The actions this worker can perform.
@@ -1003,6 +1022,9 @@ type Worker struct {
Status *WorkerStatus `json:"status,omitempty"`
Type WorkerType `json:"type"`
// WebhookId The webhook ID for the worker.
WebhookId *openapi_types.UUID `json:"webhookId,omitempty"`
// WebhookUrl The webhook URL for the worker.
WebhookUrl *string `json:"webhookUrl,omitempty"`
}
@@ -1655,6 +1677,9 @@ type ServerInterface interface {
// Delete a webhook
// (DELETE /api/v1/webhook-workers/{webhook})
WebhookDelete(ctx echo.Context, webhook openapi_types.UUID) error
// List webhook requests
// (GET /api/v1/webhook-workers/{webhook}/requests)
WebhookRequestsList(ctx echo.Context, webhook openapi_types.UUID) error
// Get worker
// (GET /api/v1/workers/{worker})
WorkerGet(ctx echo.Context, worker openapi_types.UUID, params WorkerGetParams) error
@@ -3184,6 +3209,26 @@ func (w *ServerInterfaceWrapper) WebhookDelete(ctx echo.Context) error {
return err
}
// WebhookRequestsList converts echo context to params.
func (w *ServerInterfaceWrapper) WebhookRequestsList(ctx echo.Context) error {
var err error
// ------------- Path parameter "webhook" -------------
var webhook openapi_types.UUID
err = runtime.BindStyledParameterWithLocation("simple", false, "webhook", runtime.ParamLocationPath, ctx.Param("webhook"), &webhook)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter webhook: %s", err))
}
ctx.Set(BearerAuthScopes, []string{})
ctx.Set(CookieAuthScopes, []string{})
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.WebhookRequestsList(ctx, webhook)
return err
}
// WorkerGet converts echo context to params.
func (w *ServerInterfaceWrapper) WorkerGet(ctx echo.Context) error {
var err error
@@ -3510,6 +3555,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
router.POST(baseURL+"/api/v1/users/register", wrapper.UserCreate)
router.GET(baseURL+"/api/v1/users/slack/callback", wrapper.UserUpdateSlackOauthCallback)
router.DELETE(baseURL+"/api/v1/webhook-workers/:webhook", wrapper.WebhookDelete)
router.GET(baseURL+"/api/v1/webhook-workers/:webhook/requests", wrapper.WebhookRequestsList)
router.GET(baseURL+"/api/v1/workers/:worker", wrapper.WorkerGet)
router.PATCH(baseURL+"/api/v1/workers/:worker", wrapper.WorkerUpdate)
router.GET(baseURL+"/api/v1/workflow-runs/:workflow-run/input", wrapper.WorkflowRunGetInput)
@@ -5903,6 +5949,50 @@ func (response WebhookDelete405JSONResponse) VisitWebhookDeleteResponse(w http.R
return json.NewEncoder(w).Encode(response)
}
type WebhookRequestsListRequestObject struct {
Webhook openapi_types.UUID `json:"webhook"`
}
type WebhookRequestsListResponseObject interface {
VisitWebhookRequestsListResponse(w http.ResponseWriter) error
}
type WebhookRequestsList200JSONResponse WebhookWorkerRequestListResponse
func (response WebhookRequestsList200JSONResponse) VisitWebhookRequestsListResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type WebhookRequestsList400JSONResponse APIErrors
func (response WebhookRequestsList400JSONResponse) VisitWebhookRequestsListResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(400)
return json.NewEncoder(w).Encode(response)
}
type WebhookRequestsList401JSONResponse APIErrors
func (response WebhookRequestsList401JSONResponse) VisitWebhookRequestsListResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
return json.NewEncoder(w).Encode(response)
}
type WebhookRequestsList405JSONResponse APIErrors
func (response WebhookRequestsList405JSONResponse) VisitWebhookRequestsListResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(405)
return json.NewEncoder(w).Encode(response)
}
type WorkerGetRequestObject struct {
Worker openapi_types.UUID `json:"worker"`
Params WorkerGetParams
@@ -6420,6 +6510,8 @@ type StrictServerInterface interface {
WebhookDelete(ctx echo.Context, request WebhookDeleteRequestObject) (WebhookDeleteResponseObject, error)
WebhookRequestsList(ctx echo.Context, request WebhookRequestsListRequestObject) (WebhookRequestsListResponseObject, error)
WorkerGet(ctx echo.Context, request WorkerGetRequestObject) (WorkerGetResponseObject, error)
WorkerUpdate(ctx echo.Context, request WorkerUpdateRequestObject) (WorkerUpdateResponseObject, error)
@@ -8190,6 +8282,31 @@ func (sh *strictHandler) WebhookDelete(ctx echo.Context, webhook openapi_types.U
return nil
}
// WebhookRequestsList operation middleware
func (sh *strictHandler) WebhookRequestsList(ctx echo.Context, webhook openapi_types.UUID) error {
var request WebhookRequestsListRequestObject
request.Webhook = webhook
handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
return sh.ssi.WebhookRequestsList(ctx, request.(WebhookRequestsListRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "WebhookRequestsList")
}
response, err := handler(ctx, request)
if err != nil {
return err
} else if validResponse, ok := response.(WebhookRequestsListResponseObject); ok {
return validResponse.VisitWebhookRequestsListResponse(ctx.Response())
} else if response != nil {
return fmt.Errorf("Unexpected response type: %T", response)
}
return nil
}
// WorkerGet operation middleware
func (sh *strictHandler) WorkerGet(ctx echo.Context, worker openapi_types.UUID, params WorkerGetParams) error {
var request WorkerGetRequestObject
@@ -8435,171 +8552,174 @@ func (sh *strictHandler) WorkflowVersionGetDefinition(ctx echo.Context, workflow
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
}
// GetSwagger returns the content of the embedded swagger specification file

View File

@@ -6,6 +6,14 @@ import (
"github.com/hatchet-dev/hatchet/pkg/repository/prisma/sqlchelpers"
)
func ToWebhookWorkerRequest(webhookWorker *dbsqlc.WebhookWorkerRequest) *gen.WebhookWorkerRequest {
return &gen.WebhookWorkerRequest{
CreatedAt: webhookWorker.CreatedAt.Time,
Method: webhookWorker.Method,
StatusCode: int(webhookWorker.StatusCode),
}
}
func ToWebhookWorker(webhookWorker *dbsqlc.WebhookWorker) *gen.WebhookWorker {
return &gen.WebhookWorker{
Metadata: *toAPIMetadata(

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
"github.com/hatchet-dev/hatchet/pkg/repository/prisma/db"
@@ -133,7 +134,7 @@ func ToWorker(worker *db.WorkerModel) *gen.Worker {
return res
}
func ToWorkerSqlc(worker *dbsqlc.Worker, slots *int, webhookUrl *string) *gen.Worker {
func ToWorkerSqlc(worker *dbsqlc.Worker, slots *int, webhookUrl *string, actions []pgtype.Text) *gen.Worker {
dispatcherId := uuid.MustParse(pgUUIDToStr(worker.DispatcherId))
@@ -166,9 +167,24 @@ func ToWorkerSqlc(worker *dbsqlc.Worker, slots *int, webhookUrl *string) *gen.Wo
WebhookUrl: webhookUrl,
}
if worker.WebhookId.Valid {
wid := uuid.MustParse(pgUUIDToStr(worker.WebhookId))
res.WebhookId = &wid
}
if !worker.LastHeartbeatAt.Time.IsZero() {
res.LastHeartbeatAt = &worker.LastHeartbeatAt.Time
}
if actions != nil {
apiActions := make([]string, len(actions))
for i := range actions {
apiActions[i] = actions[i].String
}
res.Actions = &apiActions
}
return res
}

View File

@@ -8,8 +8,12 @@ const convert = new AnsiToHtml({
bg: 'transparent',
});
export interface ExtendedLogLine extends LogLine {
badge?: React.ReactNode;
}
type LogProps = {
logs: LogLine[];
logs: ExtendedLogLine[];
onTopReached: () => void;
onBottomReached: () => void;
};
@@ -117,6 +121,10 @@ const LoggingComponent: React.FC<LogProps> = ({
},
];
const sortedLogs = [...showLogs].sort(
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
);
return (
<div
className="relative w-full mx-auto overflow-y-auto p-6 text-indigo-300 font-mono text-xs rounded-md max-h-[25rem] min-h-[25rem] bg-muted"
@@ -128,7 +136,7 @@ const LoggingComponent: React.FC<LogProps> = ({
Refreshing...
</div>
)}
{showLogs.map((log, i) => {
{sortedLogs.map((log, i) => {
const sanitizedHtml = DOMPurify.sanitize(convert.toHtml(log.line), {
USE_PROFILES: { html: true },
});
@@ -141,6 +149,7 @@ const LoggingComponent: React.FC<LogProps> = ({
className="pb-2 break-all overflow-x-hidden"
id={'log' + i}
>
{log.badge}
<span className="text-gray-500 mr-2 ml--2">
{new Date(log.timestamp)
.toLocaleString('sv', options)

View File

@@ -8,9 +8,15 @@ type Props = {
text: string;
className?: string;
withText?: boolean;
onCopy?: () => void;
};
const CopyToClipboard: React.FC<Props> = ({ text, className, withText }) => {
const CopyToClipboard: React.FC<Props> = ({
text,
className,
withText,
onCopy,
}) => {
const [successCopy, setSuccessCopy] = useState(false);
return (
@@ -25,7 +31,8 @@ const CopyToClipboard: React.FC<Props> = ({ text, className, withText }) => {
onClick={() => {
navigator.clipboard.writeText(text);
setSuccessCopy(true);
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
onCopy && onCopy();
setTimeout(() => {
setSuccessCopy(false);
}, 2000);

View File

@@ -0,0 +1,191 @@
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import typescript from 'react-syntax-highlighter/dist/esm/languages/hljs/typescript';
import yaml from 'react-syntax-highlighter/dist/esm/languages/hljs/yaml';
import json from 'react-syntax-highlighter/dist/esm/languages/hljs/json';
import {
anOldHope,
atomOneLight,
} from 'react-syntax-highlighter/dist/esm/styles/hljs';
import CopyToClipboard from './copy-to-clipboard';
import { useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { useTheme } from '../theme-provider';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from './button';
import { CaretSortIcon } from '@radix-ui/react-icons';
SyntaxHighlighter.registerLanguage('typescript', typescript);
SyntaxHighlighter.registerLanguage('yaml', yaml);
SyntaxHighlighter.registerLanguage('json', json);
type Secrets = Record<string, string>;
enum Formats {
TABLE = 'table',
JSON = 'json',
YAML = 'yaml',
DOTENV = 'dotenv',
CLI = 'cli',
}
export function SecretCopier({
secrets,
className,
maxHeight,
maxWidth,
copy,
onClick,
}: {
secrets: Secrets;
className?: string;
maxHeight?: string;
maxWidth?: string;
copy?: boolean;
onClick?: () => void;
}) {
const { theme } = useTheme();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [format, setFormat] = useState<Formats>(Formats.DOTENV);
const renderSecrets = () => {
switch (format) {
case Formats.JSON:
return JSON.stringify(secrets, null, 2);
case Formats.YAML:
return toYAML(secrets);
case Formats.TABLE:
return (
<table className="w-full">
<thead>
<tr>
<th>Env Var</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{Object.entries(secrets).map(([key, value]) => (
<tr key={key}>
<td>
<CopyToClipboard text={key} /> {key}
</td>
<td>
<CopyToClipboard text={value} /> {value}
</td>
</tr>
))}
</tbody>
</table>
);
case Formats.CLI:
return toCliEnv(secrets);
case Formats.DOTENV:
default:
return toDotEnv(secrets);
}
};
return (
<div className={cn(className, 'w-full h-fit relative')}>
<div className="mb-2 justify-right flex flex-row items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 data-[state=open]:bg-accent"
>
<span>{format}</span>
<CaretSortIcon className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setFormat(Formats.DOTENV)}>
{Formats.DOTENV}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFormat(Formats.CLI)}>
{Formats.CLI}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFormat(Formats.JSON)}>
{Formats.JSON}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFormat(Formats.YAML)}>
{Formats.YAML}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFormat(Formats.TABLE)}>
{Formats.TABLE}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
role="button"
tabIndex={0}
onKeyDown={() => textareaRef.current?.focus()}
onClick={() => {
textareaRef.current?.focus();
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
onClick && onClick();
}}
className="relative flex bg-muted rounded-lg"
>
{format === Formats.TABLE ? (
renderSecrets()
) : (
<SyntaxHighlighter
language="text"
style={theme === 'dark' ? anOldHope : atomOneLight}
wrapLines={false}
lineProps={{
style: { wordBreak: 'break-all', whiteSpace: 'pre-wrap' },
}}
customStyle={{
cursor: 'default',
borderRadius: '0.5rem',
maxHeight: maxHeight,
maxWidth: maxWidth,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: '0.75rem',
lineHeight: '1rem',
padding: '0.5rem',
flex: '1',
background: 'transparent',
}}
>
{renderSecrets() as string}
</SyntaxHighlighter>
)}
</div>
{copy && format !== Formats.TABLE && (
<CopyToClipboard
text={renderSecrets() as string}
withText
onCopy={() => onClick && onClick()}
/>
)}
</div>
);
}
function toDotEnv(s: Secrets) {
return Object.entries(s)
.map(([key, value]) => `${key}="${value}"`)
.join('\n');
}
function toCliEnv(s: Secrets) {
return Object.entries(s)
.map(([key, value]) => `export ${key}="${value}"`)
.join('\n');
}
function toYAML(s: Secrets) {
return Object.entries(s)
.map(([key, value]) => `${key}:"${value}"`)
.join('\n');
}

View File

@@ -70,6 +70,7 @@ import {
WebhookWorkerCreated,
WebhookWorkerCreateRequest,
WebhookWorkerListResponse,
WebhookWorkerRequestListResponse,
Worker,
WorkerList,
Workflow,
@@ -1641,6 +1642,22 @@ export class Api<SecurityDataType = unknown> extends HttpClient<SecurityDataType
secure: true,
...params,
});
/**
* @description Lists all requests for a webhook
*
* @name WebhookRequestsList
* @summary List webhook requests
* @request GET:/api/v1/webhook-workers/{webhook}/requests
* @secure
*/
webhookRequestsList = (webhook: string, params: RequestParams = {}) =>
this.request<WebhookWorkerRequestListResponse, APIErrors>({
path: `/api/v1/webhook-workers/${webhook}/requests`,
method: 'GET',
secure: true,
format: 'json',
...params,
});
/**
* @description Get the input for a workflow run.
*

View File

@@ -946,6 +946,11 @@ export interface Worker {
labels?: WorkerLabel[];
/** The webhook URL for the worker. */
webhookUrl?: string;
/**
* The webhook ID for the worker.
* @format uuid
*/
webhookId?: string;
}
export interface WorkerLabel {
@@ -1135,6 +1140,29 @@ export interface WebhookWorker {
url: string;
}
export enum WebhookWorkerRequestMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
}
export interface WebhookWorkerRequest {
/**
* The date and time the request was created.
* @format date-time
*/
created_at: string;
/** The HTTP method used for the request. */
method: WebhookWorkerRequestMethod;
/** The HTTP status code of the response. */
statusCode: number;
}
export interface WebhookWorkerRequestListResponse {
/** The list of webhook requests. */
requests?: WebhookWorkerRequest[];
}
export interface WebhookWorkerCreated {
metadata: APIResourceMeta;
/** The name of the webhook worker. */

View File

@@ -286,5 +286,10 @@ export const queries = createQueryKeyStore({
queryFn: async () =>
(await api.webhookCreate(tenant, webhookWorker)).data,
}),
listRequests: (webhookWorkerId: string) => ({
queryKey: ['webhook-worker:list:requests', webhookWorkerId],
queryFn: async () =>
(await api.webhookRequestsList(webhookWorkerId)).data,
}),
},
});

View File

@@ -11,7 +11,6 @@ import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/loading';
import { CodeHighlighter } from '@/components/ui/code-highlighter';
import {
Select,
SelectContent,
@@ -19,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SecretCopier } from '@/components/ui/secret-copier';
export const EXPIRES_IN_OPTS = {
'3 months': `${3 * 30 * 24 * 60 * 60}s`,
@@ -65,12 +65,10 @@ export function CreateTokenDialog({
This is the only time we will show you this token. Make sure to copy
it somewhere safe.
</p>
<CodeHighlighter
language="typescript"
<SecretCopier
secrets={{ HATCHET_CLIENT_TOKEN: token }}
className="text-sm"
wrapLines={false}
maxWidth={'calc(700px - 4rem)'}
code={'HATCHET_CLIENT_TOKEN="' + token + '"'}
copy
/>
</DialogContent>

View File

@@ -30,6 +30,7 @@ import { DataTable } from '@/components/molecules/data-table/data-table';
import { columns } from './components/step-runs-columns';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { RecentWebhookRequests } from '../webhooks/components/recent-webhook-requests';
export const isHealthy = (worker?: Worker) => {
const reasons = [];
@@ -136,6 +137,7 @@ export default function ExpandedWorkflowRun() {
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row gap-4 items-center justify-between">
<ServerStackIcon className="h-6 w-6 text-foreground mt-1" />
<Badge>{worker.type}</Badge>
<h2 className="text-2xl font-bold leading-tight text-foreground">
<Link to="/workers">Workers/</Link>
{worker.webhookUrl || worker.name}
@@ -263,6 +265,18 @@ export default function ExpandedWorkflowRun() {
);
})}
</div>
{worker.webhookId && (
<>
<Separator className="my-4" />
<div className="flex flex-row justify-between items-center mb-4">
<h3 className="text-xl font-bold leading-tight text-foreground">
Recent HTTP Health Checks
</h3>
</div>
<RecentWebhookRequests webhookId={worker.webhookId} />
</>
)}
<Separator className="my-4" />
<h3 className="text-xl font-bold leading-tight text-foreground mb-4">
Worker Labels

View File

@@ -11,10 +11,12 @@ import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/loading';
import { CodeHighlighter } from '@/components/ui/code-highlighter';
import { SecretCopier } from '@/components/ui/secret-copier';
import { useEffect, useState } from 'react';
import { RecentWebhookRequests } from './recent-webhook-requests';
const schema = z.object({
name: z.string().min(1).max(255),
name: z.string().max(255).optional(),
url: z.string().url().min(1).max(255),
secret: z.string().min(1).max(255).optional(),
});
@@ -22,28 +24,59 @@ const schema = z.object({
interface CreateWebhookWorkerDialogProps {
className?: string;
secret?: string;
onSubmit: (opts: z.infer<typeof schema>) => void;
webhookId?: string;
onSubmit: (opts: z.infer<typeof schema> & { name: string }) => void;
isLoading: boolean;
fieldErrors?: Record<string, string>;
isOpen: boolean;
}
export function CreateWebhookWorkerDialog({
className,
secret,
webhookId,
isOpen,
...props
}: CreateWebhookWorkerDialogProps) {
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {},
});
const [canTestConnection, setCanTestConnection] = useState(false);
const [waitingForConnection, setWaitingForConnection] = useState(false);
const [isComplete, setIsComplete] = useState(false);
const nameError = errors.name?.message?.toString() || props.fieldErrors?.name;
const urlError = errors.url?.message?.toString() || props.fieldErrors?.url;
useEffect(() => {
if (!isOpen) {
setCanTestConnection(false);
setWaitingForConnection(false);
setIsComplete(false);
reset(); // Reset form fields
}
}, [isOpen, reset]);
if (isComplete) {
return (
<DialogContent className="w-fit max-w-[700px]">
<DialogHeader>
<DialogTitle>Connected!</DialogTitle>
</DialogHeader>
<p className="text-sm">
Your webhook worker is now connected and ready to receive runs.
</p>
</DialogContent>
);
}
if (secret) {
return (
<DialogContent className="w-fit max-w-[700px]">
@@ -51,16 +84,40 @@ export function CreateWebhookWorkerDialog({
<DialogTitle>Keep it secret, keep it safe</DialogTitle>
</DialogHeader>
<p className="text-sm">
Copy the webhook secret and add it in your application.
Set the following Hatchet configuration in your application
environment:
</p>
<CodeHighlighter
language="typescript"
<SecretCopier
className="text-sm"
wrapLines={false}
maxWidth={'calc(700px - 4rem)'}
code={secret}
secrets={{
HATCHET_WEBHOOK_SECRET: secret,
}}
copy
onClick={() => setCanTestConnection(true)}
/>
<p className="text-sm text-gray-500">
These values should be kept secret and not shared with anyone. They
will only be displayed once.
</p>
<Button
onClick={() => {
setWaitingForConnection(true);
}}
disabled={!canTestConnection}
>
{waitingForConnection && <Spinner />}
Test Connection
</Button>
{waitingForConnection && webhookId && (
<RecentWebhookRequests
webhookId={webhookId}
filterBeforeNow={true}
onConnected={() => setIsComplete(true)}
/>
)}
</DialogContent>
);
}
@@ -68,37 +125,26 @@ export function CreateWebhookWorkerDialog({
return (
<DialogContent className="w-fit max-w-[80%] min-w-[500px]">
<DialogHeader>
<DialogTitle>Create a new Webhook Endpoint</DialogTitle>
<DialogTitle>Create a New Webhook Worker</DialogTitle>
</DialogHeader>
<div className={cn('grid gap-6', className)}>
<form
onSubmit={handleSubmit((d) => {
props.onSubmit(d);
const name = d.name || d.url || '';
props.onSubmit({ ...d, name });
})}
>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
{...register('name')}
id="webhook-worker-name"
name="name"
placeholder="My Webhook Endpoint"
autoCapitalize="none"
autoCorrect="off"
disabled={props.isLoading}
/>
{nameError && (
<div className="text-sm text-red-500">{nameError}</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="url">URL</Label>
<p className="text-sm dark:text-gray-400 text-gray-800">
The URL with full path where the webhook worker will be
available.
</p>
<Input
{...register('url')}
id="webhook-worker-url"
name="url"
placeholder="The Webhook URL"
autoCapitalize="none"
autoCorrect="off"
disabled={props.isLoading}
@@ -107,10 +153,27 @@ export function CreateWebhookWorkerDialog({
<div className="text-sm text-red-500">{urlError}</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="name">Friendly Name (optional)</Label>
<p className="text-sm dark:text-gray-400 text-gray-800">
An easy to remember name to identify worker.
</p>
<Input
{...register('name')}
id="webhook-worker-name"
name="name"
autoCapitalize="none"
autoCorrect="off"
disabled={props.isLoading}
/>
{nameError && (
<div className="text-sm text-red-500">{nameError}</div>
)}
</div>
<Button disabled={props.isLoading}>
{props.isLoading && <Spinner />}
Create
Continue
</Button>
</div>
</form>

View File

@@ -0,0 +1,102 @@
import LoggingComponent, {
ExtendedLogLine,
} from '@/components/cloud/logging/logs';
import { Badge } from '@/components/ui/badge';
import { queries } from '@/lib/api';
import { useQuery } from '@tanstack/react-query';
import React, { useEffect, useMemo, useState } from 'react';
interface RecentRequestProps {
webhookId: string;
onConnected?: () => void;
filterBeforeNow?: boolean;
}
const StatusCodeToMessage: Record<number, string> = {
200: 'Server can receive run requests!',
401: 'Unauthorized',
403: 'Forbidden, Check if worker path is correct',
404: 'Not Found, Check if worker path is correct',
500: 'Internal Server Error, See server worker logs',
502: 'Bad Gateway, Check if domain is correct and the server is running',
};
export const RecentWebhookRequests: React.FC<RecentRequestProps> = ({
webhookId,
onConnected,
filterBeforeNow = false,
}) => {
const [timeAfter] = useState(filterBeforeNow ? Date.now() : undefined);
const webhookRequestQuery = useQuery({
...queries.webhookWorkers.listRequests(webhookId),
refetchInterval: 1000,
});
const filteredRequests = timeAfter
? webhookRequestQuery.data?.requests?.filter(
(request) => new Date(request.created_at).getTime() > timeAfter,
)
: webhookRequestQuery.data?.requests;
useEffect(() => {
if (!onConnected) {
return;
}
if (!filteredRequests || filteredRequests.length === 0) {
return;
}
if (filteredRequests[0].statusCode === 200) {
onConnected();
}
}, [onConnected, filteredRequests]);
const logLines = useMemo(() => {
return (filteredRequests || []).map<ExtendedLogLine>((request) => {
return {
line: StatusCodeToMessage[request.statusCode],
timestamp: request.created_at,
instance: '',
badge: (
<Badge
className="mr-4"
variant={request.statusCode == 200 ? 'successful' : 'failed'}
>
{request.statusCode}
</Badge>
),
// statusCode: request.statusCode,
// createdAt: request.created_at,
// message: StatusCodeToMessage[request.statusCode],
// metadata: {},
};
});
}, [filteredRequests]);
if (webhookRequestQuery.isLoading) {
return <div>Loading...</div>;
}
if (webhookRequestQuery.isError) {
return <div>Error: {webhookRequestQuery.error?.message}</div>;
}
if (
!webhookRequestQuery.data ||
!webhookRequestQuery.data.requests ||
webhookRequestQuery.data.requests.length === 0
) {
return <div>Attempting to connect...</div>;
}
return (
<LoggingComponent
logs={logLines}
onTopReached={function (): void {}}
onBottomReached={function (): void {}}
/>
);
};

View File

@@ -10,7 +10,7 @@ import {
CardTitle,
} from '@/components/ui/card.tsx';
import { Button } from '@/components/ui/button.tsx';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useApiError } from '@/lib/hooks.ts';
import { Dialog } from '@/components/ui/dialog.tsx';
import {
@@ -22,6 +22,7 @@ import {
import { BiDotsVertical } from 'react-icons/bi';
import { CreateWebhookWorkerDialog } from './components/create-webhook-worker-dialog';
import { DeleteWebhookWorkerDialog } from './components/delete-webhook-worker-dialog';
import { Badge } from '@/components/ui/badge';
export default function Webhooks() {
const { tenant } = useOutletContext<TenantContextType>();
@@ -36,8 +37,8 @@ export default function Webhooks() {
<div className="flex-grow h-full w-full">
<div className="mx-auto max-w-7xl py-8 px-4 sm:px-6 lg:px-8">
<div className="flex flex-row justify-between items-center">
<h2 className="text-2xl font-semibold leading-tight text-foreground">
Webhook Workers (BETA)
<h2 className="text-2xl font-semibold leading-tight text-foreground flex flex-row items-center gap-2">
Webhook Workers <Badge variant="inProgress">BETA</Badge>
</h2>
<Button
@@ -48,7 +49,15 @@ export default function Webhooks() {
</Button>
</div>
<p className="text-gray-700 dark:text-gray-300 my-4">
Assign webhook workers to workflows.
Assign workflow runs to a HTTP endpoint.{' '}
<a
className="underline"
target="_blank"
href="https://docs.hatchet.run/home/features/webhooks"
rel="noreferrer"
>
Learn more.
</a>
</p>
<Separator className="my-4" />
@@ -174,6 +183,7 @@ function CreateWebhookWorker({
setShowDialog: (show: boolean) => void;
}) {
const [generatedToken, setGeneratedToken] = useState<string | undefined>();
const [webhookId, setWebhookId] = useState<string | undefined>();
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const { handleApiError } = useApiError({
setFieldErrors: setFieldErrors,
@@ -187,18 +197,29 @@ function CreateWebhookWorker({
},
onSuccess: (data) => {
setGeneratedToken(data.secret);
setWebhookId(data.metadata.id);
onSuccess();
},
onError: handleApiError,
});
useEffect(() => {
if (showDialog) {
setGeneratedToken(undefined);
setWebhookId(undefined);
setFieldErrors({});
}
}, [showDialog]);
return (
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<CreateWebhookWorkerDialog
isLoading={createWebhookWorkerMutation.isPending}
onSubmit={createWebhookWorkerMutation.mutate}
secret={generatedToken}
webhookId={webhookId}
fieldErrors={fieldErrors}
isOpen={showDialog}
/>
</Dialog>
);

View File

@@ -154,7 +154,7 @@ func (c *WebhooksController) cleanupDeletedWorker(id, tenantId string) {
Tenantid: sqlchelpers.UUIDFromStr(tenantId),
})
if err != nil {
c.sc.Logger.Err(err).Msgf("could not delete webhook worker")
c.sc.Logger.Err(err).Msgf("could not delete webhook worker worker")
return
}
@@ -162,6 +162,12 @@ func (c *WebhooksController) cleanupDeletedWorker(id, tenantId string) {
delete(c.registeredWorkerIds, id)
delete(c.cleanups, id)
c.mu.Unlock()
err = c.sc.EngineRepository.WebhookWorker().HardDeleteWebhookWorker(context.Background(), id, tenantId)
if err != nil {
c.sc.Logger.Err(err).Msgf("could not delete webhook worker")
}
}
func (c *WebhooksController) getOrCreateToken(ww *dbsqlc.WebhookWorker, tenantId string) (string, error) {
@@ -190,14 +196,15 @@ func (c *WebhooksController) getOrCreateToken(ww *dbsqlc.WebhookWorker, tenantId
encTokStr := base64.StdEncoding.EncodeToString(encTok)
_, err = c.sc.EngineRepository.WebhookWorker().UpsertWebhookWorker(context.Background(), &repository.UpsertWebhookWorkerOpts{
Name: ww.Name,
URL: ww.Url,
Secret: ww.Secret,
TenantId: tenantId,
TokenID: &tok.TokenId,
TokenValue: &encTokStr,
})
_, err = c.sc.EngineRepository.WebhookWorker().UpdateWebhookWorkerToken(
context.Background(),
sqlchelpers.UUIDToStr(ww.ID),
tenantId,
&repository.UpdateWebhookWorkerTokenOpts{
TokenID: &tok.TokenId,
TokenValue: &encTokStr,
})
if err != nil {
return "", fmt.Errorf("could not update webhook worker: %w", err)
}
@@ -215,15 +222,21 @@ func (c *WebhooksController) healthcheck(ww *dbsqlc.WebhookWorker) (*HealthCheck
return nil, err
}
resp, err := whrequest.Send(context.Background(), ww.Url, secret, struct {
resp, statusCode, err := whrequest.Send(context.Background(), ww.Url, secret, struct {
Time time.Time `json:"time"`
}{
Time: time.Now(),
}, func(req *http.Request) {
req.Method = "PUT"
})
if err != nil {
return nil, fmt.Errorf("healthcheck request: %w", err)
if statusCode != nil {
err = c.sc.EngineRepository.WebhookWorker().InsertWebhookWorkerRequest(context.Background(), sqlchelpers.UUIDToStr(ww.ID), "PUT", int32(*statusCode))
c.sc.Logger.Err(err).Msgf("could not insert webhook worker request")
}
if err != nil || *statusCode != http.StatusOK {
return nil, fmt.Errorf("health check request: %w", err)
}
var res HealthCheckResponse

View File

@@ -12,20 +12,20 @@ import (
"github.com/hatchet-dev/hatchet/internal/signature"
)
func Send(ctx context.Context, url string, secret string, data any, headers ...func(req *http.Request)) ([]byte, error) {
func Send(ctx context.Context, url string, secret string, data any, headers ...func(req *http.Request)) ([]byte, *int, error) {
body, err := json.Marshal(data)
if err != nil {
return nil, err
return nil, nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return nil, err
return nil, nil, err
}
sig, err := signature.Sign(string(body), secret)
if err != nil {
return nil, err
return nil, nil, err
}
req.Header.Set("X-Hatchet-Signature", sig)
req.Header.Set("Content-Type", "application/json")
@@ -39,22 +39,25 @@ func Send(ctx context.Context, url string, secret string, data any, headers ...f
Timeout: time.Second * 600,
}
// TODO block-list
// nolint:gosec
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
connRefused := 502
return nil, &connRefused, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("request failed with status code %d", resp.StatusCode)
return nil, &resp.StatusCode, fmt.Errorf("request failed with status code %d", resp.StatusCode)
}
res, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("could not read response body: %w", err)
return nil, &resp.StatusCode, fmt.Errorf("could not read response body: %w", err)
}
return res, nil
return res, &resp.StatusCode, nil
}

View File

@@ -963,6 +963,25 @@ type WebhookWorkerListResponse struct {
Rows *[]WebhookWorker `json:"rows,omitempty"`
}
// WebhookWorkerRequest defines model for WebhookWorkerRequest.
type WebhookWorkerRequest struct {
// CreatedAt The date and time the request was created.
CreatedAt time.Time `json:"created_at"`
Method WebhookWorkerRequestMethod `json:"method"`
// StatusCode The HTTP status code of the response.
StatusCode int `json:"statusCode"`
}
// WebhookWorkerRequestListResponse defines model for WebhookWorkerRequestListResponse.
type WebhookWorkerRequestListResponse struct {
// Requests The list of webhook requests.
Requests *[]WebhookWorkerRequest `json:"requests,omitempty"`
}
// WebhookWorkerRequestMethod defines model for WebhookWorkerRequestMethod.
type WebhookWorkerRequestMethod = interface{}
// Worker defines model for Worker.
type Worker struct {
// Actions The actions this worker can perform.
@@ -1000,6 +1019,9 @@ type Worker struct {
Status *WorkerStatus `json:"status,omitempty"`
Type WorkerType `json:"type"`
// WebhookId The webhook ID for the worker.
WebhookId *openapi_types.UUID `json:"webhookId,omitempty"`
// WebhookUrl The webhook URL for the worker.
WebhookUrl *string `json:"webhookUrl,omitempty"`
}
@@ -1761,6 +1783,9 @@ type ClientInterface interface {
// WebhookDelete request
WebhookDelete(ctx context.Context, webhook openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error)
// WebhookRequestsList request
WebhookRequestsList(ctx context.Context, webhook openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error)
// WorkerGet request
WorkerGet(ctx context.Context, worker openapi_types.UUID, params *WorkerGetParams, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -2813,6 +2838,18 @@ func (c *Client) WebhookDelete(ctx context.Context, webhook openapi_types.UUID,
return c.Client.Do(req)
}
func (c *Client) WebhookRequestsList(ctx context.Context, webhook openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewWebhookRequestsListRequest(c.Server, webhook)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) WorkerGet(ctx context.Context, worker openapi_types.UUID, params *WorkerGetParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewWorkerGetRequest(c.Server, worker, params)
if err != nil {
@@ -6057,6 +6094,40 @@ func NewWebhookDeleteRequest(server string, webhook openapi_types.UUID) (*http.R
return req, nil
}
// NewWebhookRequestsListRequest generates requests for WebhookRequestsList
func NewWebhookRequestsListRequest(server string, webhook openapi_types.UUID) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithLocation("simple", false, "webhook", runtime.ParamLocationPath, webhook)
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/v1/webhook-workers/%s/requests", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest("GET", queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewWorkerGetRequest generates requests for WorkerGet
func NewWorkerGetRequest(server string, worker openapi_types.UUID, params *WorkerGetParams) (*http.Request, error) {
var err error
@@ -6794,6 +6865,9 @@ type ClientWithResponsesInterface interface {
// WebhookDeleteWithResponse request
WebhookDeleteWithResponse(ctx context.Context, webhook openapi_types.UUID, reqEditors ...RequestEditorFn) (*WebhookDeleteResponse, error)
// WebhookRequestsListWithResponse request
WebhookRequestsListWithResponse(ctx context.Context, webhook openapi_types.UUID, reqEditors ...RequestEditorFn) (*WebhookRequestsListResponse, error)
// WorkerGetWithResponse request
WorkerGetWithResponse(ctx context.Context, worker openapi_types.UUID, params *WorkerGetParams, reqEditors ...RequestEditorFn) (*WorkerGetResponse, error)
@@ -8398,6 +8472,31 @@ func (r WebhookDeleteResponse) StatusCode() int {
return 0
}
type WebhookRequestsListResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *WebhookWorkerRequestListResponse
JSON400 *APIErrors
JSON401 *APIErrors
JSON405 *APIErrors
}
// Status returns HTTPResponse.Status
func (r WebhookRequestsListResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r WebhookRequestsListResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
type WorkerGetResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -9366,6 +9465,15 @@ func (c *ClientWithResponses) WebhookDeleteWithResponse(ctx context.Context, web
return ParseWebhookDeleteResponse(rsp)
}
// WebhookRequestsListWithResponse request returning *WebhookRequestsListResponse
func (c *ClientWithResponses) WebhookRequestsListWithResponse(ctx context.Context, webhook openapi_types.UUID, reqEditors ...RequestEditorFn) (*WebhookRequestsListResponse, error) {
rsp, err := c.WebhookRequestsList(ctx, webhook, reqEditors...)
if err != nil {
return nil, err
}
return ParseWebhookRequestsListResponse(rsp)
}
// WorkerGetWithResponse request returning *WorkerGetResponse
func (c *ClientWithResponses) WorkerGetWithResponse(ctx context.Context, worker openapi_types.UUID, params *WorkerGetParams, reqEditors ...RequestEditorFn) (*WorkerGetResponse, error) {
rsp, err := c.WorkerGet(ctx, worker, params, reqEditors...)
@@ -11983,6 +12091,53 @@ func ParseWebhookDeleteResponse(rsp *http.Response) (*WebhookDeleteResponse, err
return response, nil
}
// ParseWebhookRequestsListResponse parses an HTTP response from a WebhookRequestsListWithResponse call
func ParseWebhookRequestsListResponse(rsp *http.Response) (*WebhookRequestsListResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &WebhookRequestsListResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest WebhookWorkerRequestListResponse
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
var dest APIErrors
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON400 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
var dest APIErrors
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON401 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 405:
var dest APIErrors
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON405 = &dest
}
return response, nil
}
// ParseWorkerGetResponse parses an HTTP response from a WorkerGetWithResponse call
func ParseWorkerGetResponse(rsp *http.Response) (*WorkerGetResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)

File diff suppressed because it is too large Load Diff

View File

@@ -588,6 +588,49 @@ func (ns NullVcsProvider) Value() (driver.Value, error) {
return string(ns.VcsProvider), nil
}
type WebhookWorkerRequestMethod string
const (
WebhookWorkerRequestMethodGET WebhookWorkerRequestMethod = "GET"
WebhookWorkerRequestMethodPOST WebhookWorkerRequestMethod = "POST"
WebhookWorkerRequestMethodPUT WebhookWorkerRequestMethod = "PUT"
)
func (e *WebhookWorkerRequestMethod) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = WebhookWorkerRequestMethod(s)
case string:
*e = WebhookWorkerRequestMethod(s)
default:
return fmt.Errorf("unsupported scan type for WebhookWorkerRequestMethod: %T", src)
}
return nil
}
type NullWebhookWorkerRequestMethod struct {
WebhookWorkerRequestMethod WebhookWorkerRequestMethod `json:"WebhookWorkerRequestMethod"`
Valid bool `json:"valid"` // Valid is true if WebhookWorkerRequestMethod is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullWebhookWorkerRequestMethod) Scan(value interface{}) error {
if value == nil {
ns.WebhookWorkerRequestMethod, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.WebhookWorkerRequestMethod.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullWebhookWorkerRequestMethod) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.WebhookWorkerRequestMethod), nil
}
type WorkerLabelComparator string
const (
@@ -1235,6 +1278,14 @@ type WebhookWorker struct {
TenantId pgtype.UUID `json:"tenantId"`
}
type WebhookWorkerRequest struct {
ID pgtype.UUID `json:"id"`
CreatedAt pgtype.Timestamp `json:"createdAt"`
WebhookWorkerId pgtype.UUID `json:"webhookWorkerId"`
Method WebhookWorkerRequestMethod `json:"method"`
StatusCode int32 `json:"statusCode"`
}
type WebhookWorkerWorkflow struct {
ID pgtype.UUID `json:"id"`
WebhookWorkerId pgtype.UUID `json:"webhookWorkerId"`

View File

@@ -37,6 +37,9 @@ CREATE TYPE "TenantResourceLimitAlertType" AS ENUM ('Alarm', 'Exhausted');
-- CreateEnum
CREATE TYPE "VcsProvider" AS ENUM ('GITHUB');
-- CreateEnum
CREATE TYPE "WebhookWorkerRequestMethod" AS ENUM ('GET', 'POST', 'PUT');
-- CreateEnum
CREATE TYPE "WorkerLabelComparator" AS ENUM ('EQUAL', 'NOT_EQUAL', 'GREATER_THAN', 'GREATER_THAN_OR_EQUAL', 'LESS_THAN', 'LESS_THAN_OR_EQUAL');
@@ -608,6 +611,17 @@ CREATE TABLE "WebhookWorker" (
CONSTRAINT "WebhookWorker_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookWorkerRequest" (
"id" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"webhookWorkerId" UUID NOT NULL,
"method" "WebhookWorkerRequestMethod" NOT NULL,
"statusCode" INTEGER NOT NULL,
CONSTRAINT "WebhookWorkerRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookWorkerWorkflow" (
"id" UUID NOT NULL,
@@ -1098,6 +1112,9 @@ CREATE UNIQUE INDEX "WebhookWorker_id_key" ON "WebhookWorker"("id" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "WebhookWorker_url_key" ON "WebhookWorker"("url" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "WebhookWorkerRequest_id_key" ON "WebhookWorkerRequest"("id" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "WebhookWorkerWorkflow_id_key" ON "WebhookWorkerWorkflow"("id" ASC);
@@ -1428,6 +1445,9 @@ ALTER TABLE "WebhookWorker" ADD CONSTRAINT "WebhookWorker_tenantId_fkey" FOREIGN
-- AddForeignKey
ALTER TABLE "WebhookWorker" ADD CONSTRAINT "WebhookWorker_tokenId_fkey" FOREIGN KEY ("tokenId") REFERENCES "APIToken"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebhookWorkerRequest" ADD CONSTRAINT "WebhookWorkerRequest_webhookWorkerId_fkey" FOREIGN KEY ("webhookWorkerId") REFERENCES "WebhookWorker"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebhookWorkerWorkflow" ADD CONSTRAINT "WebhookWorkerWorkflow_webhookWorkerId_fkey" FOREIGN KEY ("webhookWorkerId") REFERENCES "WebhookWorker"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -24,8 +24,46 @@ SELECT *
FROM "WebhookWorker"
WHERE "tenantId" = @tenantId::uuid AND "deleted" = false;
-- name: ListWebhookWorkerRequests :many
SELECT *
FROM "WebhookWorkerRequest"
WHERE "webhookWorkerId" = @webhookWorkerId::uuid
ORDER BY "createdAt" DESC
LIMIT 50;
-- name: UpsertWebhookWorker :one
-- name: InsertWebhookWorkerRequest :exec
WITH delete_old AS (
-- Delete old requests
DELETE FROM "WebhookWorkerRequest"
WHERE "webhookWorkerId" = @webhookWorkerId::uuid
AND "createdAt" < NOW() - INTERVAL '15 minutes'
)
INSERT INTO "WebhookWorkerRequest" (
"id",
"createdAt",
"webhookWorkerId",
"method",
"statusCode"
) VALUES (
gen_random_uuid(),
CURRENT_TIMESTAMP,
@webhookWorkerId::uuid,
@method::"WebhookWorkerRequestMethod",
@statusCode::integer
);
-- name: UpdateWebhookWorkerToken :one
UPDATE "WebhookWorker"
SET
"updatedAt" = CURRENT_TIMESTAMP,
"tokenValue" = COALESCE(sqlc.narg('tokenValue')::text, "tokenValue"),
"tokenId" = COALESCE(sqlc.narg('tokenId')::uuid, "tokenId")
WHERE
"id" = @id::uuid
AND "tenantId" = @tenantId::uuid
RETURNING *;
-- name: CreateWebhookWorker :one
INSERT INTO "WebhookWorker" (
"id",
"createdAt",
@@ -50,20 +88,19 @@ VALUES (
sqlc.narg('tokenValue')::text,
coalesce(sqlc.narg('deleted')::boolean, false)
)
ON CONFLICT ("url") DO
UPDATE
SET
"tokenId" = coalesce(sqlc.narg('tokenId')::uuid, excluded."tokenId"),
"tokenValue" = coalesce(sqlc.narg('tokenValue')::text, excluded."tokenValue"),
"name" = coalesce(sqlc.narg('name')::text, excluded."name"),
"secret" = coalesce(sqlc.narg('secret')::text, excluded."secret"),
"url" = coalesce(sqlc.narg('url')::text, excluded."url"),
"deleted" = coalesce(sqlc.narg('deleted')::boolean, excluded."deleted")
RETURNING *;
-- name: DeleteWebhookWorker :exec
-- name: SoftDeleteWebhookWorker :exec
UPDATE "WebhookWorker"
SET "deleted" = true
SET
"deleted" = true,
"updatedAt" = CURRENT_TIMESTAMP
WHERE
"id" = @id::uuid
and "tenantId" = @tenantId::uuid;
AND "tenantId" = @tenantId::uuid;
-- name: HardDeleteWebhookWorker :exec
DELETE FROM "WebhookWorker"
WHERE
"id" = @id::uuid
AND "tenantId" = @tenantId::uuid;

View File

@@ -11,21 +11,117 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const deleteWebhookWorker = `-- name: DeleteWebhookWorker :exec
UPDATE "WebhookWorker"
SET "deleted" = true
WHERE
"id" = $1::uuid
and "tenantId" = $2::uuid
const createWebhookWorker = `-- name: CreateWebhookWorker :one
INSERT INTO "WebhookWorker" (
"id",
"createdAt",
"updatedAt",
"name",
"secret",
"url",
"tenantId",
"tokenId",
"tokenValue",
"deleted"
)
VALUES (
gen_random_uuid(),
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
$1::text,
$2::text,
$3::text,
$4::uuid,
$5::uuid,
$6::text,
coalesce($7::boolean, false)
)
RETURNING id, "createdAt", "updatedAt", name, secret, url, "tokenValue", deleted, "tokenId", "tenantId"
`
type DeleteWebhookWorkerParams struct {
type CreateWebhookWorkerParams struct {
Name string `json:"name"`
Secret string `json:"secret"`
Url string `json:"url"`
Tenantid pgtype.UUID `json:"tenantid"`
TokenId pgtype.UUID `json:"tokenId"`
TokenValue pgtype.Text `json:"tokenValue"`
Deleted pgtype.Bool `json:"deleted"`
}
func (q *Queries) CreateWebhookWorker(ctx context.Context, db DBTX, arg CreateWebhookWorkerParams) (*WebhookWorker, error) {
row := db.QueryRow(ctx, createWebhookWorker,
arg.Name,
arg.Secret,
arg.Url,
arg.Tenantid,
arg.TokenId,
arg.TokenValue,
arg.Deleted,
)
var i WebhookWorker
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Secret,
&i.Url,
&i.TokenValue,
&i.Deleted,
&i.TokenId,
&i.TenantId,
)
return &i, err
}
const hardDeleteWebhookWorker = `-- name: HardDeleteWebhookWorker :exec
DELETE FROM "WebhookWorker"
WHERE
"id" = $1::uuid
AND "tenantId" = $2::uuid
`
type HardDeleteWebhookWorkerParams struct {
ID pgtype.UUID `json:"id"`
Tenantid pgtype.UUID `json:"tenantid"`
}
func (q *Queries) DeleteWebhookWorker(ctx context.Context, db DBTX, arg DeleteWebhookWorkerParams) error {
_, err := db.Exec(ctx, deleteWebhookWorker, arg.ID, arg.Tenantid)
func (q *Queries) HardDeleteWebhookWorker(ctx context.Context, db DBTX, arg HardDeleteWebhookWorkerParams) error {
_, err := db.Exec(ctx, hardDeleteWebhookWorker, arg.ID, arg.Tenantid)
return err
}
const insertWebhookWorkerRequest = `-- name: InsertWebhookWorkerRequest :exec
WITH delete_old AS (
-- Delete old requests
DELETE FROM "WebhookWorkerRequest"
WHERE "webhookWorkerId" = $1::uuid
AND "createdAt" < NOW() - INTERVAL '15 minutes'
)
INSERT INTO "WebhookWorkerRequest" (
"id",
"createdAt",
"webhookWorkerId",
"method",
"statusCode"
) VALUES (
gen_random_uuid(),
CURRENT_TIMESTAMP,
$1::uuid,
$2::"WebhookWorkerRequestMethod",
$3::integer
)
`
type InsertWebhookWorkerRequestParams struct {
Webhookworkerid pgtype.UUID `json:"webhookworkerid"`
Method WebhookWorkerRequestMethod `json:"method"`
Statuscode int32 `json:"statuscode"`
}
func (q *Queries) InsertWebhookWorkerRequest(ctx context.Context, db DBTX, arg InsertWebhookWorkerRequestParams) error {
_, err := db.Exec(ctx, insertWebhookWorkerRequest, arg.Webhookworkerid, arg.Method, arg.Statuscode)
return err
}
@@ -66,6 +162,40 @@ func (q *Queries) ListActiveWebhookWorkers(ctx context.Context, db DBTX, tenanti
return items, nil
}
const listWebhookWorkerRequests = `-- name: ListWebhookWorkerRequests :many
SELECT id, "createdAt", "webhookWorkerId", method, "statusCode"
FROM "WebhookWorkerRequest"
WHERE "webhookWorkerId" = $1::uuid
ORDER BY "createdAt" DESC
LIMIT 50
`
func (q *Queries) ListWebhookWorkerRequests(ctx context.Context, db DBTX, webhookworkerid pgtype.UUID) ([]*WebhookWorkerRequest, error) {
rows, err := db.Query(ctx, listWebhookWorkerRequests, webhookworkerid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*WebhookWorkerRequest
for rows.Next() {
var i WebhookWorkerRequest
if err := rows.Scan(
&i.ID,
&i.CreatedAt,
&i.WebhookWorkerId,
&i.Method,
&i.StatusCode,
); err != nil {
return nil, err
}
items = append(items, &i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listWebhookWorkersByPartitionId = `-- name: ListWebhookWorkersByPartitionId :many
WITH tenants AS (
SELECT
@@ -119,62 +249,51 @@ func (q *Queries) ListWebhookWorkersByPartitionId(ctx context.Context, db DBTX,
return items, nil
}
const upsertWebhookWorker = `-- name: UpsertWebhookWorker :one
INSERT INTO "WebhookWorker" (
"id",
"createdAt",
"updatedAt",
"name",
"secret",
"url",
"tenantId",
"tokenId",
"tokenValue",
"deleted"
)
VALUES (
gen_random_uuid(),
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
$1::text,
$2::text,
$3::text,
$4::uuid,
$5::uuid,
$6::text,
coalesce($7::boolean, false)
)
ON CONFLICT ("url") DO
UPDATE
const softDeleteWebhookWorker = `-- name: SoftDeleteWebhookWorker :exec
UPDATE "WebhookWorker"
SET
"tokenId" = coalesce($5::uuid, excluded."tokenId"),
"tokenValue" = coalesce($6::text, excluded."tokenValue"),
"name" = coalesce($1::text, excluded."name"),
"secret" = coalesce($2::text, excluded."secret"),
"url" = coalesce($3::text, excluded."url"),
"deleted" = coalesce($7::boolean, excluded."deleted")
"deleted" = true,
"updatedAt" = CURRENT_TIMESTAMP
WHERE
"id" = $1::uuid
AND "tenantId" = $2::uuid
`
type SoftDeleteWebhookWorkerParams struct {
ID pgtype.UUID `json:"id"`
Tenantid pgtype.UUID `json:"tenantid"`
}
func (q *Queries) SoftDeleteWebhookWorker(ctx context.Context, db DBTX, arg SoftDeleteWebhookWorkerParams) error {
_, err := db.Exec(ctx, softDeleteWebhookWorker, arg.ID, arg.Tenantid)
return err
}
const updateWebhookWorkerToken = `-- name: UpdateWebhookWorkerToken :one
UPDATE "WebhookWorker"
SET
"updatedAt" = CURRENT_TIMESTAMP,
"tokenValue" = COALESCE($1::text, "tokenValue"),
"tokenId" = COALESCE($2::uuid, "tokenId")
WHERE
"id" = $3::uuid
AND "tenantId" = $4::uuid
RETURNING id, "createdAt", "updatedAt", name, secret, url, "tokenValue", deleted, "tokenId", "tenantId"
`
type UpsertWebhookWorkerParams struct {
Name pgtype.Text `json:"name"`
Secret pgtype.Text `json:"secret"`
Url pgtype.Text `json:"url"`
Tenantid pgtype.UUID `json:"tenantid"`
TokenId pgtype.UUID `json:"tokenId"`
type UpdateWebhookWorkerTokenParams struct {
TokenValue pgtype.Text `json:"tokenValue"`
Deleted pgtype.Bool `json:"deleted"`
TokenId pgtype.UUID `json:"tokenId"`
ID pgtype.UUID `json:"id"`
Tenantid pgtype.UUID `json:"tenantid"`
}
func (q *Queries) UpsertWebhookWorker(ctx context.Context, db DBTX, arg UpsertWebhookWorkerParams) (*WebhookWorker, error) {
row := db.QueryRow(ctx, upsertWebhookWorker,
arg.Name,
arg.Secret,
arg.Url,
arg.Tenantid,
arg.TokenId,
func (q *Queries) UpdateWebhookWorkerToken(ctx context.Context, db DBTX, arg UpdateWebhookWorkerTokenParams) (*WebhookWorker, error) {
row := db.QueryRow(ctx, updateWebhookWorkerToken,
arg.TokenValue,
arg.Deleted,
arg.TokenId,
arg.ID,
arg.Tenantid,
)
var i WebhookWorker
err := row.Scan(

View File

@@ -2,6 +2,7 @@
SELECT
sqlc.embed(workers),
ww."url" AS "webhookUrl",
ww."id" AS "webhookId",
(SELECT COUNT(*) FROM "WorkerSemaphoreSlot" wss WHERE wss."workerId" = workers."id" AND wss."stepRunId" IS NOT NULL) AS "slots"
FROM
"Worker" workers
@@ -32,18 +33,34 @@ WHERE
))
)
GROUP BY
workers."id", ww."url";
workers."id", ww."url", ww."id";
-- name: GetWorkerById :one
SELECT
sqlc.embed(workers),
ww."url" AS "webhookUrl"
sqlc.embed(w),
ww."url" AS "webhookUrl",
(
SELECT COUNT(*)
FROM "WorkerSemaphoreSlot"
WHERE "workerId" = w.id AND "stepRunId" IS NOT NULL
) AS filled_slots
FROM
"Worker" workers
"Worker" w
LEFT JOIN
"WebhookWorker" ww ON workers."webhookId" = ww."id"
"WebhookWorker" ww ON w."webhookId" = ww."id"
WHERE
workers."id" = @id::uuid;
w."id" = @id::uuid;
-- name: GetWorkerActionsByWorkerId :many
SELECT
a."actionId" AS actionId
FROM "Worker" w
LEFT JOIN "_ActionToWorker" aw ON w.id = aw."B"
LEFT JOIN "Action" a ON aw."A" = a.id
WHERE
a."tenantId" = @tenantId::uuid AND
w."id" = @workerId::uuid;
-- name: StubWorkerSemaphoreSlots :exec
INSERT INTO "WorkerSemaphoreSlot" ("id", "workerId")

View File

@@ -103,21 +103,63 @@ func (q *Queries) DeleteWorker(ctx context.Context, db DBTX, id pgtype.UUID) (*W
return &i, err
}
const getWorkerActionsByWorkerId = `-- name: GetWorkerActionsByWorkerId :many
SELECT
a."actionId" AS actionId
FROM "Worker" w
LEFT JOIN "_ActionToWorker" aw ON w.id = aw."B"
LEFT JOIN "Action" a ON aw."A" = a.id
WHERE
a."tenantId" = $1::uuid AND
w."id" = $2::uuid
`
type GetWorkerActionsByWorkerIdParams struct {
Tenantid pgtype.UUID `json:"tenantid"`
Workerid pgtype.UUID `json:"workerid"`
}
func (q *Queries) GetWorkerActionsByWorkerId(ctx context.Context, db DBTX, arg GetWorkerActionsByWorkerIdParams) ([]pgtype.Text, error) {
rows, err := db.Query(ctx, getWorkerActionsByWorkerId, arg.Tenantid, arg.Workerid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []pgtype.Text
for rows.Next() {
var actionid pgtype.Text
if err := rows.Scan(&actionid); err != nil {
return nil, err
}
items = append(items, actionid)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWorkerById = `-- name: GetWorkerById :one
SELECT
workers.id, workers."createdAt", workers."updatedAt", workers."deletedAt", workers."tenantId", workers."lastHeartbeatAt", workers.name, workers."dispatcherId", workers."maxRuns", workers."isActive", workers."lastListenerEstablished", workers."isPaused", workers.type, workers."webhookId",
ww."url" AS "webhookUrl"
w.id, w."createdAt", w."updatedAt", w."deletedAt", w."tenantId", w."lastHeartbeatAt", w.name, w."dispatcherId", w."maxRuns", w."isActive", w."lastListenerEstablished", w."isPaused", w.type, w."webhookId",
ww."url" AS "webhookUrl",
(
SELECT COUNT(*)
FROM "WorkerSemaphoreSlot"
WHERE "workerId" = w.id AND "stepRunId" IS NOT NULL
) AS filled_slots
FROM
"Worker" workers
"Worker" w
LEFT JOIN
"WebhookWorker" ww ON workers."webhookId" = ww."id"
"WebhookWorker" ww ON w."webhookId" = ww."id"
WHERE
workers."id" = $1::uuid
w."id" = $1::uuid
`
type GetWorkerByIdRow struct {
Worker Worker `json:"worker"`
WebhookUrl pgtype.Text `json:"webhookUrl"`
Worker Worker `json:"worker"`
WebhookUrl pgtype.Text `json:"webhookUrl"`
FilledSlots int64 `json:"filled_slots"`
}
func (q *Queries) GetWorkerById(ctx context.Context, db DBTX, id pgtype.UUID) (*GetWorkerByIdRow, error) {
@@ -139,6 +181,7 @@ func (q *Queries) GetWorkerById(ctx context.Context, db DBTX, id pgtype.UUID) (*
&i.Worker.Type,
&i.Worker.WebhookId,
&i.WebhookUrl,
&i.FilledSlots,
)
return &i, err
}
@@ -465,6 +508,7 @@ const listWorkersWithStepCount = `-- name: ListWorkersWithStepCount :many
SELECT
workers.id, workers."createdAt", workers."updatedAt", workers."deletedAt", workers."tenantId", workers."lastHeartbeatAt", workers.name, workers."dispatcherId", workers."maxRuns", workers."isActive", workers."lastListenerEstablished", workers."isPaused", workers.type, workers."webhookId",
ww."url" AS "webhookUrl",
ww."id" AS "webhookId",
(SELECT COUNT(*) FROM "WorkerSemaphoreSlot" wss WHERE wss."workerId" = workers."id" AND wss."stepRunId" IS NOT NULL) AS "slots"
FROM
"Worker" workers
@@ -495,7 +539,7 @@ WHERE
))
)
GROUP BY
workers."id", ww."url"
workers."id", ww."url", ww."id"
`
type ListWorkersWithStepCountParams struct {
@@ -508,6 +552,7 @@ type ListWorkersWithStepCountParams struct {
type ListWorkersWithStepCountRow struct {
Worker Worker `json:"worker"`
WebhookUrl pgtype.Text `json:"webhookUrl"`
WebhookId pgtype.UUID `json:"webhookId"`
Slots int64 `json:"slots"`
}
@@ -541,6 +586,7 @@ func (q *Queries) ListWorkersWithStepCount(ctx context.Context, db DBTX, arg Lis
&i.Worker.Type,
&i.Worker.WebhookId,
&i.WebhookUrl,
&i.WebhookId,
&i.Slots,
); err != nil {
return nil, err

View File

@@ -3,7 +3,9 @@ package prisma
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog"
@@ -43,16 +45,32 @@ func (r *webhookWorkerEngineRepository) ListActiveWebhookWorkers(ctx context.Con
return r.queries.ListActiveWebhookWorkers(ctx, r.pool, sqlchelpers.UUIDFromStr(tenantId))
}
func (r *webhookWorkerEngineRepository) UpsertWebhookWorker(ctx context.Context, opts *repository.UpsertWebhookWorkerOpts) (*dbsqlc.WebhookWorker, error) {
func (r *webhookWorkerEngineRepository) ListWebhookWorkerRequests(ctx context.Context, webhookWorkerId string) ([]*dbsqlc.WebhookWorkerRequest, error) {
return r.queries.ListWebhookWorkerRequests(ctx, r.pool, sqlchelpers.UUIDFromStr(webhookWorkerId))
}
func (r *webhookWorkerEngineRepository) InsertWebhookWorkerRequest(ctx context.Context, webhookWorkerId string, method string, statusCode int32) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return r.queries.InsertWebhookWorkerRequest(ctx, r.pool, dbsqlc.InsertWebhookWorkerRequestParams{
Webhookworkerid: sqlchelpers.UUIDFromStr(webhookWorkerId),
Method: dbsqlc.WebhookWorkerRequestMethod(method),
Statuscode: statusCode,
})
}
func (r *webhookWorkerEngineRepository) CreateWebhookWorker(ctx context.Context, opts *repository.CreateWebhookWorkerOpts) (*dbsqlc.WebhookWorker, error) {
if err := r.v.Validate(opts); err != nil {
return nil, err
}
params := dbsqlc.UpsertWebhookWorkerParams{
params := dbsqlc.CreateWebhookWorkerParams{
Tenantid: sqlchelpers.UUIDFromStr(opts.TenantId),
Name: sqlchelpers.TextFromStr(opts.Name),
Secret: sqlchelpers.TextFromStr(opts.Secret),
Url: sqlchelpers.TextFromStr(opts.URL),
Name: opts.Name,
Secret: opts.Secret,
Url: opts.URL,
}
if opts.Deleted != nil {
@@ -67,11 +85,54 @@ func (r *webhookWorkerEngineRepository) UpsertWebhookWorker(ctx context.Context,
params.TokenValue = sqlchelpers.TextFromStr(*opts.TokenValue)
}
return r.queries.UpsertWebhookWorker(ctx, r.pool, params)
worker, err := r.queries.CreateWebhookWorker(ctx, r.pool, params)
if err != nil {
if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == "23505" {
return nil, repository.ErrDuplicateKey
}
return nil, err
}
return worker, nil
}
func (r *webhookWorkerEngineRepository) DeleteWebhookWorker(ctx context.Context, id string, tenantId string) error {
return r.queries.DeleteWebhookWorker(ctx, r.pool, dbsqlc.DeleteWebhookWorkerParams{
func (r *webhookWorkerEngineRepository) UpdateWebhookWorkerToken(ctx context.Context, id string, tenantId string, opts *repository.UpdateWebhookWorkerTokenOpts) (*dbsqlc.WebhookWorker, error) {
if err := r.v.Validate(opts); err != nil {
return nil, err
}
params := dbsqlc.UpdateWebhookWorkerTokenParams{
ID: sqlchelpers.UUIDFromStr(id),
Tenantid: sqlchelpers.UUIDFromStr(tenantId),
}
if opts.TokenID != nil {
params.TokenId = sqlchelpers.UUIDFromStr(*opts.TokenID)
}
if opts.TokenValue != nil {
params.TokenValue = sqlchelpers.TextFromStr(*opts.TokenValue)
}
worker, err := r.queries.UpdateWebhookWorkerToken(ctx, r.pool, params)
if err != nil {
return nil, err
}
return worker, nil
}
func (r *webhookWorkerEngineRepository) SoftDeleteWebhookWorker(ctx context.Context, id string, tenantId string) error {
return r.queries.SoftDeleteWebhookWorker(ctx, r.pool, dbsqlc.SoftDeleteWebhookWorkerParams{
ID: sqlchelpers.UUIDFromStr(id),
Tenantid: sqlchelpers.UUIDFromStr(tenantId),
})
}
func (r *webhookWorkerEngineRepository) HardDeleteWebhookWorker(ctx context.Context, id string, tenantId string) error {
return r.queries.HardDeleteWebhookWorker(ctx, r.pool, dbsqlc.HardDeleteWebhookWorkerParams{
ID: sqlchelpers.UUIDFromStr(id),
Tenantid: sqlchelpers.UUIDFromStr(tenantId),
})

View File

@@ -45,6 +45,13 @@ func (w *workerAPIRepository) GetWorkerById(workerId string) (*dbsqlc.GetWorkerB
return w.queries.GetWorkerById(context.Background(), w.pool, sqlchelpers.UUIDFromStr(workerId))
}
func (w *workerAPIRepository) GetWorkerActionsByWorkerId(tenantid, workerId string) ([]pgtype.Text, error) {
return w.queries.GetWorkerActionsByWorkerId(context.Background(), w.pool, dbsqlc.GetWorkerActionsByWorkerIdParams{
Workerid: sqlchelpers.UUIDFromStr(workerId),
Tenantid: sqlchelpers.UUIDFromStr(tenantid),
})
}
func (w *workerAPIRepository) ListWorkerState(tenantId, workerId string, failed bool) ([]*dbsqlc.ListSemaphoreSlotsWithStateForWorkerRow, []*dbsqlc.ListRecentStepRunsForWorkerRow, error) {
slots, err := w.queries.ListSemaphoreSlotsWithStateForWorker(context.Background(), w.pool, dbsqlc.ListSemaphoreSlotsWithStateForWorkerParams{
Workerid: sqlchelpers.UUIDFromStr(workerId),

View File

@@ -2,11 +2,12 @@ package repository
import (
"context"
"fmt"
"github.com/hatchet-dev/hatchet/pkg/repository/prisma/dbsqlc"
)
type UpsertWebhookWorkerOpts struct {
type CreateWebhookWorkerOpts struct {
Name string
URL string `validate:"required,url"`
Secret string
@@ -16,6 +17,13 @@ type UpsertWebhookWorkerOpts struct {
TokenID *string
}
type UpdateWebhookWorkerTokenOpts struct {
TokenValue *string
TokenID *string
}
var ErrDuplicateKey = fmt.Errorf("duplicate key error")
type WebhookWorkerEngineRepository interface {
// ListWebhookWorkersByPartitionId returns the list of webhook workers for a worker partition
ListWebhookWorkersByPartitionId(ctx context.Context, partitionId string) ([]*dbsqlc.WebhookWorker, error)
@@ -23,9 +31,21 @@ type WebhookWorkerEngineRepository interface {
// ListActiveWebhookWorkers returns the list of active webhook workers for the given tenant
ListActiveWebhookWorkers(ctx context.Context, tenantId string) ([]*dbsqlc.WebhookWorker, error)
// UpsertWebhookWorker creates a new webhook worker with the given options
UpsertWebhookWorker(ctx context.Context, opts *UpsertWebhookWorkerOpts) (*dbsqlc.WebhookWorker, error)
// ListWebhookWorkerRequests returns the list of webhook worker requests for the given webhook worker id
ListWebhookWorkerRequests(ctx context.Context, webhookWorkerId string) ([]*dbsqlc.WebhookWorkerRequest, error)
// DeleteWebhookWorker deletes a webhook worker with the given id and tenant id
DeleteWebhookWorker(ctx context.Context, id string, tenantId string) error
// InsertWebhookWorkerRequest inserts a new webhook worker request with the given options
InsertWebhookWorkerRequest(ctx context.Context, webhookWorkerId string, method string, statusCode int32) error
// CreateWebhookWorker creates a new webhook worker with the given options
CreateWebhookWorker(ctx context.Context, opts *CreateWebhookWorkerOpts) (*dbsqlc.WebhookWorker, error)
// UpdateWebhookWorkerToken updates a webhook worker with the given id and tenant id
UpdateWebhookWorkerToken(ctx context.Context, id string, tenantId string, opts *UpdateWebhookWorkerTokenOpts) (*dbsqlc.WebhookWorker, error)
// SoftDeleteWebhookWorker flags a webhook worker for delete with the given id and tenant id
SoftDeleteWebhookWorker(ctx context.Context, id string, tenantId string) error
// HardDeleteWebhookWorker deletes a webhook worker with the given id and tenant id
HardDeleteWebhookWorker(ctx context.Context, id string, tenantId string) error
}

View File

@@ -74,6 +74,9 @@ type WorkerAPIRepository interface {
// ListRecentWorkerStepRuns lists recent step runs for a given worker
ListWorkerState(tenantId, workerId string, failed bool) ([]*dbsqlc.ListSemaphoreSlotsWithStateForWorkerRow, []*dbsqlc.ListRecentStepRunsForWorkerRow, error)
// GetWorkerActionsByWorkerId returns a list of actions for a worker
GetWorkerActionsByWorkerId(tenantid, workerId string) ([]pgtype.Text, error)
// GetWorkerById returns a worker by its id.
GetWorkerById(workerId string) (*dbsqlc.GetWorkerByIdRow, error)

View File

@@ -119,7 +119,13 @@ func (w *Worker) sendWebhook(ctx context.Context, action *client.Action, ww Webh
Action: action,
ActionPayload: string(action.ActionPayload),
}
_, err := whrequest.Send(ctx, ww.URL, ww.Secret, actionWithPayload)
_, statusCode, err := whrequest.Send(ctx, ww.URL, ww.Secret, actionWithPayload)
if statusCode != nil && *statusCode != 200 {
w.l.Debug().Msgf("step run %s webhook sent with status code %d", action.StepRunId, *statusCode)
}
if err != nil {
w.l.Warn().Msgf("step run %s could not send webhook to %s: %s", action.StepRunId, ww.URL, err)
if err := w.markFailed(action, fmt.Errorf("could not send webhook: %w", err)); err != nil {

View File

@@ -0,0 +1,19 @@
-- CreateEnum
CREATE TYPE "WebhookWorkerRequestMethod" AS ENUM ('GET', 'POST', 'PUT');
-- CreateTable
CREATE TABLE "WebhookWorkerRequest" (
"id" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"webhookWorkerId" UUID NOT NULL,
"method" "WebhookWorkerRequestMethod" NOT NULL,
"statusCode" INTEGER NOT NULL,
CONSTRAINT "WebhookWorkerRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WebhookWorkerRequest_id_key" ON "WebhookWorkerRequest"("id");
-- AddForeignKey
ALTER TABLE "WebhookWorkerRequest" ADD CONSTRAINT "WebhookWorkerRequest_webhookWorkerId_fkey" FOREIGN KEY ("webhookWorkerId") REFERENCES "WebhookWorker"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -112,6 +112,29 @@ model WebhookWorker {
webhookWorkerWorkflows WebhookWorkerWorkflow[]
worker Worker?
requests WebhookWorkerRequest[]
}
enum WebhookWorkerRequestMethod {
GET
POST
PUT
}
model WebhookWorkerRequest {
id String @id @unique @default(uuid()) @db.Uuid
createdAt DateTime @default(now())
// the parent webhook worker
webhookWorker WebhookWorker @relation(fields: [webhookWorkerId], references: [id], onDelete: Cascade, onUpdate: Cascade)
webhookWorkerId String @db.Uuid
// the request method
method WebhookWorkerRequestMethod
// the request status code
statusCode Int
}
model WebhookWorkerWorkflow {

View File

@@ -0,0 +1,6 @@
-- Create enum type "WebhookWorkerRequestMethod"
CREATE TYPE "WebhookWorkerRequestMethod" AS ENUM ('GET', 'POST', 'PUT');
-- Create "WebhookWorkerRequest" table
CREATE TABLE "WebhookWorkerRequest" ("id" uuid NOT NULL, "createdAt" timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "webhookWorkerId" uuid NOT NULL, "method" "WebhookWorkerRequestMethod" NOT NULL, "statusCode" integer NOT NULL, PRIMARY KEY ("id"), CONSTRAINT "WebhookWorkerRequest_webhookWorkerId_fkey" FOREIGN KEY ("webhookWorkerId") REFERENCES "WebhookWorker" ("id") ON UPDATE CASCADE ON DELETE CASCADE);
-- Create index "WebhookWorkerRequest_id_key" to table: "WebhookWorkerRequest"
CREATE UNIQUE INDEX "WebhookWorkerRequest_id_key" ON "WebhookWorkerRequest" ("id");

View File

@@ -1,4 +1,4 @@
h1:ZCdX1Ul6UwDyP8dEntES43VPqA+bJvD4ht0vwdH0jRs=
h1:BXNv7MdMKFP6K9x+yDK4ltddqlcL29+Nvc4PIQC/J00=
20240115180414_init.sql h1:Ef3ZyjAHkmJPdGF/dEWCahbwgcg6uGJKnDxW2JCRi2k=
20240122014727_v0_6_0.sql h1:o/LdlteAeFgoHJ3e/M4Xnghqt9826IE/Y/h0q95Acuo=
20240126235456_v0_7_0.sql h1:KiVzt/hXgQ6esbdC6OMJOOWuYEXmy1yeCpmsVAHTFKs=
@@ -47,3 +47,4 @@ h1:ZCdX1Ul6UwDyP8dEntES43VPqA+bJvD4ht0vwdH0jRs=
20240812153737_v0.42.1.sql h1:dUADhy9vhbGwdrn+h2KqshcdfSUvSUOlF190Sy6xGhw=
20240815151244_v0.42.2.sql h1:t1vDmgBTS6QpPIUH+QSgqGIQQ4a7E2g2eA0wHei4jj4=
20240821170947_0.42.3.sql h1:eh6eC0fUreFdyVm0pC2D6B/3Cffo/t9SQjf2Ai+6Eu0=
20240823120430_0.42.4.sql h1:kdfT+J0j21YBvohnF5k+qtt+4YU6egi4fLIaReDucmc=

View File

@@ -37,6 +37,9 @@ CREATE TYPE "TenantResourceLimitAlertType" AS ENUM ('Alarm', 'Exhausted');
-- CreateEnum
CREATE TYPE "VcsProvider" AS ENUM ('GITHUB');
-- CreateEnum
CREATE TYPE "WebhookWorkerRequestMethod" AS ENUM ('GET', 'POST', 'PUT');
-- CreateEnum
CREATE TYPE "WorkerLabelComparator" AS ENUM ('EQUAL', 'NOT_EQUAL', 'GREATER_THAN', 'GREATER_THAN_OR_EQUAL', 'LESS_THAN', 'LESS_THAN_OR_EQUAL');
@@ -608,6 +611,17 @@ CREATE TABLE "WebhookWorker" (
CONSTRAINT "WebhookWorker_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookWorkerRequest" (
"id" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"webhookWorkerId" UUID NOT NULL,
"method" "WebhookWorkerRequestMethod" NOT NULL,
"statusCode" INTEGER NOT NULL,
CONSTRAINT "WebhookWorkerRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WebhookWorkerWorkflow" (
"id" UUID NOT NULL,
@@ -1098,6 +1112,9 @@ CREATE UNIQUE INDEX "WebhookWorker_id_key" ON "WebhookWorker"("id" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "WebhookWorker_url_key" ON "WebhookWorker"("url" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "WebhookWorkerRequest_id_key" ON "WebhookWorkerRequest"("id" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "WebhookWorkerWorkflow_id_key" ON "WebhookWorkerWorkflow"("id" ASC);
@@ -1428,6 +1445,9 @@ ALTER TABLE "WebhookWorker" ADD CONSTRAINT "WebhookWorker_tenantId_fkey" FOREIGN
-- AddForeignKey
ALTER TABLE "WebhookWorker" ADD CONSTRAINT "WebhookWorker_tokenId_fkey" FOREIGN KEY ("tokenId") REFERENCES "APIToken"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebhookWorkerRequest" ADD CONSTRAINT "WebhookWorkerRequest_webhookWorkerId_fkey" FOREIGN KEY ("webhookWorkerId") REFERENCES "WebhookWorker"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WebhookWorkerWorkflow" ADD CONSTRAINT "WebhookWorkerWorkflow_webhookWorkerId_fkey" FOREIGN KEY ("webhookWorkerId") REFERENCES "WebhookWorker"("id") ON DELETE CASCADE ON UPDATE CASCADE;