diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cd11acb..dcfc5c3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@urql/core": "^4.1.1", "@urql/svelte": "^4.0.4", "fuse.js": "^7.0.0", + "graphql-ws": "^5.16.0", "underscore": "^1.13.6" }, "devDependencies": { @@ -807,6 +808,26 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphql": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-ws": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.16.0.tgz", + "integrity": "sha512-Ju2RCU2dQMgSKtArPbEtsK5gNLnsQyTNIo/T7cZNp96niC1x0KdJNZV0TIoilceBPQwfb5itrGl8pkFeOUMl4A==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index f131183..903b777 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@urql/core": "^4.1.1", "@urql/svelte": "^4.0.4", "fuse.js": "^7.0.0", + "graphql-ws": "^5.16.0", "underscore": "^1.13.6" } } diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 3efa0b0..264bb4a 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d2d982bc8b0a5ab3099ac8adbdf56b66 \ No newline at end of file +15620fe1e6786ed80d4dff1a5fa96dc3 \ No newline at end of file diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 4a59ebc..f80a13c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,8 +1,9 @@ + +{#if $items.length > 0 } + {#each $items as item} + + + + + {#each Object.values(item) as cell } + {cell} + {/each} + + {/each} +{/if} + + + diff --git a/frontend/src/JsonTable.svelte b/frontend/src/JsonTable.svelte index fc55048..5a1b1dc 100644 --- a/frontend/src/JsonTable.svelte +++ b/frontend/src/JsonTable.svelte @@ -1,9 +1,14 @@ -{#if (!displayedData)} - Dataset does not exist -{:else if displayedData.length === 0} - Dataset is empty -{:else if displayedData.length > 0} +{#if randomStore && $randomStore.data && stores.length > 0} + {@const [[_, obj]] = Object.entries($randomStore.data)} +
- {$searchTerm.charAt(0).toUpperCase() + $searchTerm.slice(1) + "s" + "(" + displayedData.length + ")"} + {$searchTerm.charAt(0).toUpperCase() + $searchTerm.slice(1) + "s" + "(" + $rowCount + ")"}
- - {#each Object.keys(displayedData[0]) as header} - + + {#each Object.keys(transform(obj)) as key} + {/each} - + - {#each Object.entries(displayedData) as [id, obj] } - {#if id == activeRowIndex} - - {#each Object.values(obj) as val } - - {/each} - - {:else } - - {#each Object.values(obj) as val } - - {/each} - - {/if} + {#each stores as store} + + + {/each}
- {header} - Context + {key} +
{val}
{val}
{store.contextName}
diff --git a/frontend/src/Legend.svelte b/frontend/src/Legend.svelte index 14f8cf9..6f64145 100644 --- a/frontend/src/Legend.svelte +++ b/frontend/src/Legend.svelte @@ -54,8 +54,10 @@ // update active contexts if (toggle) { + console.log("INFO: adding context store:", name) addContextStore.set(name) } else { + console.log("INFO: removing context store: ", name) removeContextStore.set(name) } } diff --git a/frontend/src/activeContextStore.ts b/frontend/src/activeContextStore.ts index e38c26e..8b96d61 100644 --- a/frontend/src/activeContextStore.ts +++ b/frontend/src/activeContextStore.ts @@ -19,9 +19,11 @@ addContextStore.subscribe((context) => { activeContextStore.update((allContexts) => { let resourceQuery = new GqlResourceQuery(context) allContexts.set(context, resourceQuery) + // console.log(allContexts) // clear addContextStore after activeContextStore is updated - addContextStore.set(null) + // this calls addtional unnecessary invocations? + // addContextStore.set(null) return allContexts }) @@ -32,15 +34,17 @@ export async function execActiveContexts(activeContextMap, queryVars, execAll) { if (execAll) { activeContextMap.forEach((queryObject, contextName) => { if (contextName != "" && contextName != null) { - queryObject.executeQuery(queryVars) - fetchContextData(queryObject) + queryObject.executeSubscription(queryVars) + // queryObject.fetchDataFromStore() + // fetchContextData(queryObject) } }) } else { activeContextMap.forEach((queryObject, contextName) => { if (!queryObject.queryIssued && contextName != "" && contextName != null) { - queryObject.executeQuery(queryVars) - fetchContextData(queryObject) + queryObject.executeSubscription(queryVars) + // queryObject.fetchDataFromStore() + // fetchContextData(queryObject) } }) } @@ -50,33 +54,76 @@ export async function execActiveContexts(activeContextMap, queryVars, execAll) { async function fetchContextData(queryObject) { const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) let retries = 0 - let queryStore = queryObject.queryStore + let store = queryObject.queryStore ?? queryObject.subscriptionStore let fetching, error, data - queryStore.subscribe(store => { + store.subscribe(store => { fetching = store.fetching error = store.error data = store.data }) + // works with bugs + // store.subscribe(store => { + // if (store.error) { + // console.log("ERROR: GraphQL Query Store: ", queryObject.contextName) + // throw new Error(error) + // } + // else if (store.data) { + // // update tableDataStore with fetched data + // tableDataStore.update(m => { + // const resourceObject = queryObject.transform(store.data) + // + // // cluster context exists in map, append object to array + // if (m.has(queryObject.contextName)) { + // m.get(queryObject.contextName).set(resourceObject.uid, resourceObject) + // } else { // map entry dne + // m.set(queryObject.contextName, new Map()) + // m.get(queryObject.contextName).set(resourceObject.uid, resourceObject) + // } + // queryObject.queryIssued = true + // return m + // }) + // } + // }) while (retries < 40) { - if (fetching) { - console.log("INFO: GraphQL Query Store fetching: ", queryObject.contextName) - } else if (error) { + // if (fetching) { + // console.log("INFO: GraphQL Query Store fetching: ", queryObject.contextName) + // } else + if (error) { console.log("ERROR: GraphQL Query Store: ", queryObject.contextName) throw new Error(error) } else if (data) { + // update tableDataStore with fetched data + // tableDataStore.update(m => { + // m.set(queryObject.contextName, queryObject.transform(data)) + // return m + // }) + // queryObject.queryIssued = true + // return + console.log("Updating") // update tableDataStore with fetched data tableDataStore.update(m => { - m.set(queryObject.contextName, queryObject.transform(data)) + const resourceObject = queryObject.transform(data) + console.log(resourceObject) + // cluster context exists in map, append object to array + if (m.has(queryObject.contextName)) { + m.get(queryObject.contextName).set(resourceObject.uid, resourceObject) + } else { // map entry dne + m.set(queryObject.contextName, new Map()) + m.get(queryObject.contextName).set(resourceObject.uid, resourceObject) + } return m }) + queryObject.queryIssued = true return } + if (retries >= 39) { console.log("INFO: GraphQL Query Store retries exhausted: ", queryObject.contextName) return } + retries++ await delay(250) } @@ -98,5 +145,22 @@ removeContextStore.subscribe((context) => { return tableData }) - removeContextStore.set(null) -}) \ No newline at end of file + // removeContextStore.set(null) +}) + +// subscribe to active contexts updates +// activeContextStore.subscribe( activeContexts => { +// +// // for each active context, update the table store +// activeContexts.forEach( activeContext => { +// const contextName = activeContext.contextName +// activeContext.fetchDataFromStore() +// // tableDataStore.update( m => { +// // if (!m.has(contextName)) { +// // m.set(contextName, new Map()) +// // } +// // return m +// // }) +// }) +// +// }) \ No newline at end of file diff --git a/frontend/src/gqlQuery.ts b/frontend/src/gqlQuery.ts index 07bbedb..01a0d60 100644 --- a/frontend/src/gqlQuery.ts +++ b/frontend/src/gqlQuery.ts @@ -1,6 +1,14 @@ -import type {AnyVariables, Client, OperationResult, OperationResultStore, TypedDocumentNode} from "@urql/svelte"; +import { + type AnyVariables, + type Client, + type OperationResult, + type OperationResultStore, + subscriptionStore, + type TypedDocumentNode +} from "@urql/svelte"; import {getContextClient, gql, queryStore} from "@urql/svelte"; import type {tableObject} from "./jsonTable"; +import {resourceClass} from "./resourceQuery"; // BaseQuery implements BaseQueryInterface export class BaseQuery { @@ -9,9 +17,12 @@ export class BaseQuery { readonly bodyQueryString: string readonly footerQueryString: string readonly contextName: string + data: any + transformedData: any client: Client - queryIssued: boolean = false + querySuccess: boolean = false queryStore: OperationResultStore + subscriptionStore: OperationResultStore enableTemplating: boolean constructor(contextName: string, debug?: boolean) { @@ -48,9 +59,48 @@ export class BaseQuery { query: this.enableTemplating ? this.templateContext() : this.query, variables }) + this.data = this.queryStore.subscribe( store => store.data) + this.transform() } - transform(resultObj: OperationResult): tableObject { - return {} + executeSubscription(variables?: any) { + if (!this.client){ + // Note: getContextClient() must be called from within a svelte component! + this.client = getContextClient() + } + this.subscriptionStore = subscriptionStore({ + client: this.client, + query: this.enableTemplating ? this.templateContext() : this.query, + variables + }) + this.data = this.subscriptionStore.subscribe( store => store.data) + this.transform() + + } + + transform() { + let obj + // TODO is this necessary? + Object.entries(this.data).map(([i, v]) => { // loop over context objects + const r = v as resourceClass + // TODO https://basarat.gitbook.io/typescript/future-javascript/destructuring + obj = { + "cluster": i, + "uid": r.metadata.uid, + "eventType": r.eventType, + "name": r.metadata.name, + "namespace": r.metadata.namespace, + "kind": r.kind, + "apiVersion": r.apiVersion, + "labels": r.metadata.labels, + "annotations": r.metadata.annotations + } + }) + + this.transformedData = obj + } + + async fetchDataFromStore(){ + this.data = {} } } \ No newline at end of file diff --git a/frontend/src/jsonTable.ts b/frontend/src/jsonTable.ts index cd9ae00..c89a9e1 100644 --- a/frontend/src/jsonTable.ts +++ b/frontend/src/jsonTable.ts @@ -7,7 +7,7 @@ const filterOptions = { threshold: 0.40 // 0 = perfect match, 1 = indiscriminate } -export const tableDataStore: Writable> = writable(new Map()) +export const tableDataStore: Writable>> = writable(new Map()) export const searchTerm: Writable = writable("") export const filterTerm: Writable = writable() diff --git a/frontend/src/resourceQuery.ts b/frontend/src/resourceQuery.ts index 36b3110..80ff6c8 100644 --- a/frontend/src/resourceQuery.ts +++ b/frontend/src/resourceQuery.ts @@ -2,13 +2,17 @@ import type {OperationResult} from "@urql/svelte"; import type {tableObject} from "./jsonTable"; import {BaseQuery} from "./gqlQuery"; -// resourceClass represent the structure of the graphql resource object -class resourceClass { +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) + +// resourceClass represents the structure of the graphql resource object +export class resourceClass { + eventType: string apiVersion: string kind: string metadata: { name: string namespace: string + uid: string labels: { //TODO } @@ -20,8 +24,9 @@ class resourceClass { export class GqlResourceQuery extends BaseQuery { enableTemplating = true - rootQueryString = `query Query($name: String!) {\n` + rootQueryString = `subscription Subscription($name: String!) {\n` bodyQueryString = `PARAM-PLACEHOLDER: resources(clusterContext: "CONTEXT-PLACEHOLDER", name: $name) { + eventType apiVersion kind metadata { @@ -29,28 +34,69 @@ export class GqlResourceQuery extends BaseQuery { labels name namespace + uid } }\n` footerQueryString = `}` - transform(resultObj: OperationResult): tableObject { - let obj = [] - Object.entries(resultObj).map(([i, v]) => { // loop over context objects - Object.entries(v).map(([ii, vv]) => { // loop over resource objects - const r = vv as resourceClass - // TODO https://basarat.gitbook.io/typescript/future-javascript/destructuring - obj.push({ - "cluster": i, - "name": r.metadata.name, - "namespace": r.metadata.namespace, - "kind": r.kind, - "apiVersion": r.apiVersion, - "labels": r.metadata.labels, - "annotations": r.metadata.annotations - }) - } - ) - }) - return obj - } -} \ No newline at end of file + // transform() { + // let obj + // // TODO is this necessary? + // Object.entries(this.data).map(([i, v]) => { // loop over context objects + // const r = v as resourceClass + // // TODO https://basarat.gitbook.io/typescript/future-javascript/destructuring + // obj = { + // "cluster": i, + // "uid": r.metadata.uid, + // "eventType": r.eventType, + // "name": r.metadata.name, + // "namespace": r.metadata.namespace, + // "kind": r.kind, + // "apiVersion": r.apiVersion, + // "labels": r.metadata.labels, + // "annotations": r.metadata.annotations + // } + // }) + // + // this.transformedData = obj + // } +} + +// async fetchDataFromStore() { +// const store = this.queryStore ?? this.subscriptionStore +// +// // only check for fetching if non-subscription query +// const checkIfFetching = this.queryStore ? true : false +// +// let fetching, error, data +// let retries = 0 +// +// store.subscribe(store => { +// fetching = store.fetching +// error = store.error +// data = store.data +// }) +// +// while (retries < 40) { +// if (checkIfFetching && fetching) { +// console.log("INFO: GraphQL Query Store fetching: ", this.contextName) +// } else if (error) { +// console.log("ERROR: GraphQL Query Store: ", this.contextName) +// throw new Error(error) +// } else if (data) { +// console.log("CORIN", data) +// this.data = this.transform() +// this.querySuccess = true +// console.log("SUCCESS: GraphQL Query Store: ", this.contextName) +// return +// } +// +// if (retries >= 39) { +// console.log("INFO: GraphQL Query Store retries exhausted: ", this.contextName) +// return +// } +// +// retries++ +// await delay(250) +// } +// } \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 0b0e8de..c0b7d22 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -9,4 +9,21 @@ export function flattenResourceObj(data) { obj.push({...ObjectMeta, ...TypeMeta, ...rest }) }) return obj -} \ No newline at end of file +} + +export function transform(obj) { + return { + // "cluster": id, + "uid": obj.metadata.uid, + "eventType": obj.eventType, + "name": obj.metadata.name, + "namespace": obj.metadata.namespace, + "kind": obj.kind, + "apiVersion": obj.apiVersion, + "labels": obj.metadata.labels, + "annotations": obj.metadata.annotations + } +} + +import {writable} from "svelte/store"; +export const rowCount = writable(0); diff --git a/go.mod b/go.mod index fec9ccc..bd054be 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/99designs/gqlgen v0.17.47 + github.com/gorilla/websocket v1.5.0 github.com/mitchellh/mapstructure v1.5.0 github.com/rs/cors v1.11.0 github.com/sirupsen/logrus v1.9.3 @@ -30,7 +31,6 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect diff --git a/internal/api/graph/generated.go b/internal/api/graph/generated.go index de988c6..2eaad8a 100644 --- a/internal/api/graph/generated.go +++ b/internal/api/graph/generated.go @@ -8,6 +8,7 @@ import ( "embed" "errors" "fmt" + "io" "m8/internal/api/graph/model" "strconv" "sync" @@ -40,6 +41,7 @@ type Config struct { type ResolverRoot interface { Query() QueryResolver + Subscription() SubscriptionResolver } type DirectiveRoot struct { @@ -47,10 +49,12 @@ type DirectiveRoot struct { type ComplexityRoot struct { Metadata struct { - Annotations func(childComplexity int) int - Labels func(childComplexity int) int - Name func(childComplexity int) int - Namespace func(childComplexity int) int + Annotations func(childComplexity int) int + CreationTimestamp func(childComplexity int) int + Labels func(childComplexity int) int + Name func(childComplexity int) int + Namespace func(childComplexity int) int + UID func(childComplexity int) int } Query struct { @@ -60,17 +64,25 @@ type ComplexityRoot struct { Resource struct { APIVersion func(childComplexity int) int + EventType func(childComplexity int) int Kind func(childComplexity int) int Metadata func(childComplexity int) int Spec func(childComplexity int) int Status func(childComplexity int) int } + + Subscription struct { + Resources func(childComplexity int, name string, clusterContext string, namespace *string) int + } } type QueryResolver interface { Resources(ctx context.Context, name string, clusterContext string, namespace *string) ([]*model.Resource, error) Contexts(ctx context.Context) ([]*string, error) } +type SubscriptionResolver interface { + Resources(ctx context.Context, name string, clusterContext string, namespace *string) (<-chan *model.Resource, error) +} type executableSchema struct { schema *ast.Schema @@ -98,6 +110,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Metadata.Annotations(childComplexity), true + case "Metadata.creationTimestamp": + if e.complexity.Metadata.CreationTimestamp == nil { + break + } + + return e.complexity.Metadata.CreationTimestamp(childComplexity), true + case "Metadata.labels": if e.complexity.Metadata.Labels == nil { break @@ -119,6 +138,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Metadata.Namespace(childComplexity), true + case "Metadata.uid": + if e.complexity.Metadata.UID == nil { + break + } + + return e.complexity.Metadata.UID(childComplexity), true + case "Query.contexts": if e.complexity.Query.Contexts == nil { break @@ -145,6 +171,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Resource.APIVersion(childComplexity), true + case "Resource.eventType": + if e.complexity.Resource.EventType == nil { + break + } + + return e.complexity.Resource.EventType(childComplexity), true + case "Resource.kind": if e.complexity.Resource.Kind == nil { break @@ -173,6 +206,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Resource.Status(childComplexity), true + case "Subscription.resources": + if e.complexity.Subscription.Resources == nil { + break + } + + args, err := ec.field_Subscription_resources_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Subscription.Resources(childComplexity, args["name"].(string), args["clusterContext"].(string), args["namespace"].(*string)), true + } return 0, false } @@ -214,6 +259,23 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { return &response } + case ast.Subscription: + next := ec._Subscription(ctx, rc.Operation.SelectionSet) + + var buf bytes.Buffer + return func(ctx context.Context) *graphql.Response { + buf.Reset() + data := next(ctx) + + if data == nil { + return nil + } + data.MarshalGQL(&buf) + + return &graphql.Response{ + Data: buf.Bytes(), + } + } default: return graphql.OneShot(graphql.ErrorResponse(ctx, "unsupported GraphQL operation")) @@ -329,6 +391,39 @@ func (ec *executionContext) field_Query_resources_args(ctx context.Context, rawA return args, nil } +func (ec *executionContext) field_Subscription_resources_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 string + if tmp, ok := rawArgs["name"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) + arg0, err = ec.unmarshalNString2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["name"] = arg0 + var arg1 string + if tmp, ok := rawArgs["clusterContext"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("clusterContext")) + arg1, err = ec.unmarshalNString2string(ctx, tmp) + if err != nil { + return nil, err + } + } + args["clusterContext"] = arg1 + var arg2 *string + if tmp, ok := rawArgs["namespace"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("namespace")) + arg2, err = ec.unmarshalOString2ᚖstring(ctx, tmp) + if err != nil { + return nil, err + } + } + args["namespace"] = arg2 + return args, nil +} + func (ec *executionContext) field___Type_enumValues_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -531,6 +626,88 @@ func (ec *executionContext) fieldContext_Metadata_annotations(_ context.Context, return fc, nil } +func (ec *executionContext) _Metadata_uid(ctx context.Context, field graphql.CollectedField, obj *model.Metadata) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Metadata_uid(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.UID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Metadata_uid(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Metadata", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Metadata_creationTimestamp(ctx context.Context, field graphql.CollectedField, obj *model.Metadata) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Metadata_creationTimestamp(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.CreationTimestamp, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Metadata_creationTimestamp(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Metadata", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Query_resources(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query_resources(ctx, field) if err != nil { @@ -567,6 +744,8 @@ func (ec *executionContext) fieldContext_Query_resources(ctx context.Context, fi IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { + case "eventType": + return ec.fieldContext_Resource_eventType(ctx, field) case "metadata": return ec.fieldContext_Resource_metadata(ctx, field) case "spec": @@ -765,6 +944,47 @@ func (ec *executionContext) fieldContext_Query___schema(_ context.Context, field return fc, nil } +func (ec *executionContext) _Resource_eventType(ctx context.Context, field graphql.CollectedField, obj *model.Resource) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Resource_eventType(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.EventType, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*string) + fc.Result = res + return ec.marshalOString2ᚖstring(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Resource_eventType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Resource", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Resource_metadata(ctx context.Context, field graphql.CollectedField, obj *model.Resource) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Resource_metadata(ctx, field) if err != nil { @@ -809,6 +1029,10 @@ func (ec *executionContext) fieldContext_Resource_metadata(_ context.Context, fi return ec.fieldContext_Metadata_labels(ctx, field) case "annotations": return ec.fieldContext_Metadata_annotations(ctx, field) + case "uid": + return ec.fieldContext_Metadata_uid(ctx, field) + case "creationTimestamp": + return ec.fieldContext_Metadata_creationTimestamp(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Metadata", field.Name) }, @@ -980,6 +1204,86 @@ func (ec *executionContext) fieldContext_Resource_apiVersion(_ context.Context, return fc, nil } +func (ec *executionContext) _Subscription_resources(ctx context.Context, field graphql.CollectedField) (ret func(ctx context.Context) graphql.Marshaler) { + fc, err := ec.fieldContext_Subscription_resources(ctx, field) + if err != nil { + return nil + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Subscription().Resources(rctx, fc.Args["name"].(string), fc.Args["clusterContext"].(string), fc.Args["namespace"].(*string)) + }) + if err != nil { + ec.Error(ctx, err) + return nil + } + if resTmp == nil { + return nil + } + return func(ctx context.Context) graphql.Marshaler { + select { + case res, ok := <-resTmp.(<-chan *model.Resource): + if !ok { + return nil + } + return graphql.WriterFunc(func(w io.Writer) { + w.Write([]byte{'{'}) + graphql.MarshalString(field.Alias).MarshalGQL(w) + w.Write([]byte{':'}) + ec.marshalOResource2ᚖm8ᚋinternalᚋapiᚋgraphᚋmodelᚐResource(ctx, field.Selections, res).MarshalGQL(w) + w.Write([]byte{'}'}) + }) + case <-ctx.Done(): + return nil + } + } +} + +func (ec *executionContext) fieldContext_Subscription_resources(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Subscription", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "eventType": + return ec.fieldContext_Resource_eventType(ctx, field) + case "metadata": + return ec.fieldContext_Resource_metadata(ctx, field) + case "spec": + return ec.fieldContext_Resource_spec(ctx, field) + case "status": + return ec.fieldContext_Resource_status(ctx, field) + case "kind": + return ec.fieldContext_Resource_kind(ctx, field) + case "apiVersion": + return ec.fieldContext_Resource_apiVersion(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Resource", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Subscription_resources_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) ___Directive_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) { fc, err := ec.fieldContext___Directive_name(ctx, field) if err != nil { @@ -2780,6 +3084,10 @@ func (ec *executionContext) _Metadata(ctx context.Context, sel ast.SelectionSet, out.Values[i] = ec._Metadata_labels(ctx, field, obj) case "annotations": out.Values[i] = ec._Metadata_annotations(ctx, field, obj) + case "uid": + out.Values[i] = ec._Metadata_uid(ctx, field, obj) + case "creationTimestamp": + out.Values[i] = ec._Metadata_creationTimestamp(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -2902,6 +3210,8 @@ func (ec *executionContext) _Resource(ctx context.Context, sel ast.SelectionSet, switch field.Name { case "__typename": out.Values[i] = graphql.MarshalString("Resource") + case "eventType": + out.Values[i] = ec._Resource_eventType(ctx, field, obj) case "metadata": out.Values[i] = ec._Resource_metadata(ctx, field, obj) case "spec": @@ -2935,6 +3245,26 @@ func (ec *executionContext) _Resource(ctx context.Context, sel ast.SelectionSet, return out } +var subscriptionImplementors = []string{"Subscription"} + +func (ec *executionContext) _Subscription(ctx context.Context, sel ast.SelectionSet) func(ctx context.Context) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, subscriptionImplementors) + ctx = graphql.WithFieldContext(ctx, &graphql.FieldContext{ + Object: "Subscription", + }) + if len(fields) != 1 { + ec.Errorf(ctx, "must subscribe to exactly one stream") + return nil + } + + switch fields[0].Name { + case "resources": + return ec._Subscription_resources(ctx, fields[0]) + default: + panic("unknown field " + strconv.Quote(fields[0].Name)) + } +} + var __DirectiveImplementors = []string{"__Directive"} func (ec *executionContext) ___Directive(ctx context.Context, sel ast.SelectionSet, obj *introspection.Directive) graphql.Marshaler { diff --git a/internal/api/graph/model/models_gen.go b/internal/api/graph/model/models_gen.go index 069984d..24de89e 100644 --- a/internal/api/graph/model/models_gen.go +++ b/internal/api/graph/model/models_gen.go @@ -3,19 +3,25 @@ package model type Metadata struct { - Namespace *string `json:"namespace,omitempty"` - Name *string `json:"name,omitempty"` - Labels map[string]interface{} `json:"labels,omitempty"` - Annotations map[string]interface{} `json:"annotations,omitempty"` + Namespace *string `json:"namespace,omitempty"` + Name *string `json:"name,omitempty"` + Labels map[string]interface{} `json:"labels,omitempty"` + Annotations map[string]interface{} `json:"annotations,omitempty"` + UID *string `json:"uid,omitempty"` + CreationTimestamp *string `json:"creationTimestamp,omitempty"` } type Query struct { } type Resource struct { + EventType *string `json:"eventType,omitempty"` Metadata *Metadata `json:"metadata,omitempty"` Spec map[string]interface{} `json:"spec,omitempty"` Status map[string]interface{} `json:"status,omitempty"` Kind *string `json:"kind,omitempty"` APIVersion *string `json:"apiVersion,omitempty"` } + +type Subscription struct { +} diff --git a/internal/api/graph/resolvers.go b/internal/api/graph/resolvers.go index bbc4cbf..178e927 100644 --- a/internal/api/graph/resolvers.go +++ b/internal/api/graph/resolvers.go @@ -7,6 +7,10 @@ package graph import ( "context" "m8/internal/api/graph/model" + + "github.com/mitchellh/mapstructure" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/watch" ) // Resources is the resolver for the resources field. @@ -33,7 +37,48 @@ func (r *queryResolver) Contexts(ctx context.Context) ([]*string, error) { return contexts, nil } +// Resources is the resolver for the resources field. +func (r *subscriptionResolver) Resources(ctx context.Context, name string, clusterContext string, namespace *string) (<-chan *model.Resource, error) { + ns := "" + if namespace != nil { + ns = *namespace + } + watcher, err := r.Clusters[clusterContext].Watch(name, ns) + if err != nil { + // TODO + } + + eventChannel := make(chan *model.Resource) + + // destructures events and adds objects to the eventChannel that is used in the graphql subscription + go func(w watch.Interface, eventChannel chan<- *model.Resource) { + watchChannel := w.ResultChan() + for { + select { + case event := <-watchChannel: + if event.Object != nil { + // TODO: when searching for node .. panic: interface conversion: runtime.Object is *v1.Status, not *unstructured.Unstructured + object := event.Object.(*unstructured.Unstructured).Object + + var resource model.Resource + // decode unstructured object into resource type + err = mapstructure.Decode(object, &resource) + tp := string(event.Type) + resource.EventType = &tp + eventChannel <- &resource + } + } + } + }(watcher, eventChannel) + + return eventChannel, nil +} + // Query returns QueryResolver implementation. func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } +// Subscription returns SubscriptionResolver implementation. +func (r *Resolver) Subscription() SubscriptionResolver { return &subscriptionResolver{r} } + type queryResolver struct{ *Resolver } +type subscriptionResolver struct{ *Resolver } diff --git a/internal/api/graph/schema.graphqls b/internal/api/graph/schema.graphqls index 156622f..082e8e9 100644 --- a/internal/api/graph/schema.graphqls +++ b/internal/api/graph/schema.graphqls @@ -2,8 +2,10 @@ # Gqlgen predefined scalar scalar Map +scalar Any type Resource { + eventType: String metadata: Metadata spec: Map status: Map @@ -21,4 +23,10 @@ type Metadata { name: String labels: Map annotations: Map + uid: String + creationTimestamp: String } + +type Subscription { + resources(name: String!, clusterContext: String!, namespace: String): Resource +} \ No newline at end of file diff --git a/internal/app/server.go b/internal/app/server.go index 8a2314b..4af80e1 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -2,11 +2,16 @@ package app import ( "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/handler/extension" + "github.com/99designs/gqlgen/graphql/handler/lru" + "github.com/99designs/gqlgen/graphql/handler/transport" + "github.com/gorilla/websocket" "github.com/rs/cors" "log" "m8/internal/api/graph" "net/http" "os" + "time" ) const defaultPort = "8080" @@ -29,24 +34,51 @@ var apolloHtml = []byte(` `) +// checkOrigin enables Apollo sandbox access by bypassing cors policy +func checkOrigin(r *http.Request) bool { + return r.Host == "localhost:8080" && r.RequestURI == "/graphql" +} + +// start init http handlers and start the server func start(m8 *App) { port := os.Getenv("GRAPHQL_PORT") if port == "" { port = defaultPort } - // GqlGen main handler - srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: &graph.Resolver{ + // GqlGen handler + srv := handler.New(graph.NewExecutableSchema(graph.Config{Resolvers: &graph.Resolver{ Clusters: m8.Clusters, ContextList: m8.Contexts, - }})) + }}, + )) + + // Customize graph handler + upgrader := websocket.Upgrader{ + CheckOrigin: checkOrigin, + } + srv.AddTransport(transport.Websocket{ + Upgrader: upgrader, + KeepAlivePingInterval: 10 * time.Second, + }) + srv.AddTransport(transport.Options{}) + srv.AddTransport(transport.GET{}) + srv.AddTransport(transport.POST{}) + srv.AddTransport(transport.MultipartForm{}) + srv.SetQueryCache(lru.New(1000)) + srv.Use(extension.Introspection{}) + srv.Use(extension.AutomaticPersistedQuery{ + Cache: lru.New(100), + }) + + // enable cors access for frontend graphqlHandlerWithCors := cors.Default().Handler(srv) http.Handle("/graphql", graphqlHandlerWithCors) // Apollo handler - if m8.Apollo { - http.Handle("/sandbox", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(apolloHtml) })) - } + //if m8.Apollo { + http.Handle("/sandbox", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(apolloHtml) })) + //} log.Printf("connect to http://localhost:%s/sandbox for Apollo GraphQL UI", port) log.Fatal(http.ListenAndServe(":"+port, nil)) diff --git a/internal/client/client.go b/internal/client/client.go index 5ad7afe..157ac85 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -2,12 +2,14 @@ package client import ( "context" + "errors" "github.com/mitchellh/mapstructure" log "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/version" + "k8s.io/apimachinery/pkg/watch" disc "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes/scheme" @@ -198,3 +200,27 @@ func (c *Client) GetResources(name string, ns string) ([]*model.Resource, error) return unstrucList, nil } + +func (c *Client) Watch(name string, ns string) (watch.Interface, error) { + if c.Active == false { + err := c.lazyLoad() + if err != nil { + log.Errorln("unable to lazy load the cluster client", err) + } + } + listOptions := metav1.ListOptions{} + name = strings.ToLower(name) + + gvr, err := c.GvrFromName(name) + if err != nil { + log.Warnln("Bad resource name") + } + resource := c.dynamicClient.Resource(gvr) + + watcher, err := resource.Namespace(ns).Watch(context.TODO(), listOptions) + if watcher == nil { + return nil, errors.New("nil watcher") + } + watcher.ResultChan() + return watcher, err +}