diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 495d06c5f..8f186e531 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -369,6 +369,11 @@ jobs: # (#89). See the file header for the rows and expected classes. run: sqlite3 test-fixtures/e2e-fixture.db < test-fixtures/seed-2073-route-adverts.sql + - name: Seed an inactive observer node for #199 E2E + # Also after the migration: the fixture has no inactive_nodes table + # until corescope-migrate creates it. + run: sqlite3 test-fixtures/e2e-fixture.db < test-fixtures/seed-199-inactive-observer.sql + - name: Start Go server with fixture DB run: | fuser -k 13581/tcp 2>/dev/null || true @@ -439,6 +444,7 @@ jobs: BASE_URL=http://localhost:13581 node test-live-multibyte-only-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-125-live-toggles-early-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1150-404-state-e2e.js 2>&1 | tee -a e2e-output.txt + BASE_URL=http://localhost:13581 node test-issue-199-inactive-observer-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1146-path-link-contrast-e2e.js 2>&1 | tee -a e2e-output.txt CHROMIUM_REQUIRE=1 node test-issue-1705-subpath-contrast-e2e.js 2>&1 | tee -a e2e-output.txt BASE_URL=http://localhost:13581 node test-issue-1147-section-order-e2e.js 2>&1 | tee -a e2e-output.txt diff --git a/cmd/ingestor/db.go b/cmd/ingestor/db.go index 042af9153..ed2019524 100644 --- a/cmd/ingestor/db.go +++ b/cmd/ingestor/db.go @@ -1895,6 +1895,16 @@ func (s *Store) LogStats() { ) } +// staleNodesWhere selects the nodes MoveStaleNodes retires: no advert in +// nodeDays (?1 = cutoff), unless the pubkey is an observer seen since the +// cutoff (#199). An observer never hears its own adverts, so an online +// observer can go nodeDays without one reaching another observer. last_seen +// stays the last advert. Observer ids arrive upper-case from the MQTT topic +// and node keys are lower-case, hence lower() on both sides; NULL ids are +// skipped because a single NULL makes NOT IN false for every node. +const staleNodesWhere = `last_seen < ?1 AND lower(public_key) NOT IN ( + SELECT lower(id) FROM observers WHERE id IS NOT NULL AND last_seen >= ?1)` + // MoveStaleNodes moves nodes not seen in nodeDays to the inactive_nodes table. // Returns the number of nodes moved. func (s *Store) MoveStaleNodes(nodeDays int) (int64, error) { @@ -1905,11 +1915,11 @@ func (s *Store) MoveStaleNodes(nodeDays int) (int64, error) { } defer tx.Rollback() - _, err = tx.Exec(`INSERT OR REPLACE INTO inactive_nodes SELECT * FROM nodes WHERE last_seen < ?`, cutoff) + _, err = tx.Exec(`INSERT OR REPLACE INTO inactive_nodes SELECT * FROM nodes WHERE `+staleNodesWhere, cutoff) if err != nil { return 0, fmt.Errorf("insert inactive: %w", err) } - result, err := tx.Exec(`DELETE FROM nodes WHERE last_seen < ?`, cutoff) + result, err := tx.Exec(`DELETE FROM nodes WHERE `+staleNodesWhere, cutoff) if err != nil { return 0, fmt.Errorf("delete stale: %w", err) } diff --git a/cmd/ingestor/issue199_test.go b/cmd/ingestor/issue199_test.go new file mode 100644 index 000000000..ff8567869 --- /dev/null +++ b/cmd/ingestor/issue199_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "testing" + "time" +) + +// #199: MoveStaleNodes must not retire a node whose pubkey is an observer +// seen within nodeDays. An observer never hears its own adverts, so an +// online observer can go a week without one reaching another observer. + +const ( + issue199ObserverKey = "a199000000000000000000000000000000000000000000000000000000000001" + issue199QuietKey = "a199000000000000000000000000000000000000000000000000000000000002" + issue199PlainKey = "a199000000000000000000000000000000000000000000000000000000000003" + issue199OldAdvert = "2020-01-01T00:00:00Z" +) + +func issue199Upper(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'a' && c <= 'z' { + b[i] = c - 'a' + 'A' + } + } + return string(b) +} + +func issue199NodeIn(t *testing.T, store *Store, table, pubkey string) (bool, string) { + t.Helper() + var lastSeen string + err := store.db.QueryRow(`SELECT last_seen FROM `+table+` WHERE public_key = ?`, pubkey).Scan(&lastSeen) + if err != nil { + return false, "" + } + return true, lastSeen +} + +func issue199Observer(t *testing.T, store *Store, id, lastSeen string) { + t.Helper() + if _, err := store.db.Exec(`INSERT INTO observers (id, name, last_seen, first_seen) VALUES (?, 'obs', ?, ?)`, id, lastSeen, lastSeen); err != nil { + t.Fatal(err) + } +} + +func TestMoveStaleNodesKeepsActiveObserverNode(t *testing.T) { + store := newTestStore(t) + if err := store.UpsertNode(issue199ObserverKey, "ObsRepeater", "repeater", nil, nil, issue199OldAdvert); err != nil { + t.Fatal(err) + } + // Observer ids are upper-case pubkeys from the MQTT topic; node keys are lower-case. + issue199Observer(t, store, issue199Upper(issue199ObserverKey), time.Now().UTC().Format(time.RFC3339)) + + moved, err := store.MoveStaleNodes(7) + if err != nil { + t.Fatal(err) + } + if moved != 0 { + t.Errorf("moved=%d, want 0: an observer seen within nodeDays must stay a node", moved) + } + in, lastSeen := issue199NodeIn(t, store, "nodes", issue199ObserverKey) + if !in { + t.Fatal("active observer's node was removed from nodes") + } + if lastSeen != issue199OldAdvert { + t.Errorf("last_seen=%q, want %q: last_seen is the last advert and must not be faked", lastSeen, issue199OldAdvert) + } + if in, _ := issue199NodeIn(t, store, "inactive_nodes", issue199ObserverKey); in { + t.Error("active observer's node was copied to inactive_nodes") + } +} + +func TestMoveStaleNodesRetiresQuietObserverAndPlainNode(t *testing.T) { + store := newTestStore(t) + for _, pk := range []string{issue199QuietKey, issue199PlainKey} { + if err := store.UpsertNode(pk, "Stale", "repeater", nil, nil, issue199OldAdvert); err != nil { + t.Fatal(err) + } + } + // Last heard as an observer 8 days ago: outside the 7-day window. + issue199Observer(t, store, issue199Upper(issue199QuietKey), time.Now().UTC().AddDate(0, 0, -8).Format(time.RFC3339)) + + moved, err := store.MoveStaleNodes(7) + if err != nil { + t.Fatal(err) + } + if moved != 2 { + t.Errorf("moved=%d, want 2", moved) + } + for _, pk := range []string{issue199QuietKey, issue199PlainKey} { + if in, _ := issue199NodeIn(t, store, "nodes", pk); in { + t.Errorf("%s still in nodes, want retired", pk[:8]) + } + if in, _ := issue199NodeIn(t, store, "inactive_nodes", pk); !in { + t.Errorf("%s missing from inactive_nodes", pk[:8]) + } + } +} + +// A legacy observers row with a NULL id must not stop retention: with a bare +// NOT IN, one NULL in the subquery makes the predicate NULL for every node. +func TestMoveStaleNodesNullObserverIDDoesNotBlockRetention(t *testing.T) { + store := newTestStore(t) + if err := store.UpsertNode(issue199PlainKey, "Stale", "companion", nil, nil, issue199OldAdvert); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(`INSERT INTO observers (id, name, last_seen) VALUES (NULL, 'legacy', ?)`, time.Now().UTC().Format(time.RFC3339)); err != nil { + t.Fatal(err) + } + + moved, err := store.MoveStaleNodes(7) + if err != nil { + t.Fatal(err) + } + if moved != 1 { + t.Errorf("moved=%d, want 1", moved) + } +} diff --git a/cmd/server/issue199_node_not_found_test.go b/cmd/server/issue199_node_not_found_test.go new file mode 100644 index 000000000..086dbdbaf --- /dev/null +++ b/cmd/server/issue199_node_not_found_test.go @@ -0,0 +1,155 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/mux" +) + +// #199: a 404 from /api/nodes/{pubkey} carries what the instance still knows +// about the key -- its inactive_nodes row and/or its observer row -- so the +// node page can explain the state instead of dead-ending on "Node not found". + +const ( + issue199InactiveObs = "b199000000000000000000000000000000000000000000000000000000000001" + issue199ObsOnly = "b199000000000000000000000000000000000000000000000000000000000002" + issue199Unknown = "b199000000000000000000000000000000000000000000000000000000000003" +) + +// Decoded locally (not via the server's types) so the contract is pinned by +// its JSON field names. +type issue199Inactive struct { + PublicKey string `json:"public_key"` + Name string `json:"name"` + Role string `json:"role"` + LastSeen string `json:"last_seen"` +} + +type issue199Observer struct { + ID string `json:"id"` + Name string `json:"name"` + LastSeen string `json:"last_seen"` +} + +func issue199Get(t *testing.T, router *mux.Router, pubkey string) (int, map[string]json.RawMessage) { + t.Helper() + req := httptest.NewRequest("GET", "/api/nodes/"+pubkey, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + var body map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode %q: %v", w.Body.String(), err) + } + return w.Code, body +} + +func issue199Seed(t *testing.T, srv *Server) { + t.Helper() + ensureInactiveNodesTable(t, srv) + if _, err := srv.db.conn.Exec(`INSERT INTO inactive_nodes (public_key, name, role, last_seen, first_seen) VALUES (?, 'Quiet Repeater', 'repeater', '2026-09-24T15:35:00Z', '2026-09-11T00:00:00Z')`, issue199InactiveObs); err != nil { + t.Fatal(err) + } + for _, o := range []struct{ id, name string }{ + {strings.ToUpper(issue199InactiveObs), "Quiet Observer"}, + {strings.ToUpper(issue199ObsOnly), "Listener Only"}, + } { + if _, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, last_seen, first_seen) VALUES (?, ?, '2026-10-04T04:23:00Z', '2026-09-11T00:00:00Z')`, o.id, o.name); err != nil { + t.Fatal(err) + } + } +} + +func TestNodeDetail404CarriesInactiveNodeAndObserver(t *testing.T) { + srv, router := setupTestServer(t) + issue199Seed(t, srv) + + code, body := issue199Get(t, router, issue199InactiveObs) + if code != 404 { + t.Fatalf("status=%d, want 404 (the node is not in nodes)", code) + } + var inactive issue199Inactive + if err := json.Unmarshal(body["inactive_node"], &inactive); err != nil { + t.Fatalf("inactive_node missing or malformed: %v (body %v)", err, body) + } + if inactive.PublicKey != issue199InactiveObs || inactive.Name != "Quiet Repeater" || inactive.Role != "repeater" || inactive.LastSeen != "2026-09-24T15:35:00Z" { + t.Errorf("inactive_node=%+v", inactive) + } + var obs issue199Observer + if err := json.Unmarshal(body["observer"], &obs); err != nil { + t.Fatalf("observer missing or malformed: %v (body %v)", err, body) + } + if obs.ID != strings.ToUpper(issue199InactiveObs) || obs.Name != "Quiet Observer" || obs.LastSeen != "2026-10-04T04:23:00Z" { + t.Errorf("observer=%+v", obs) + } + + // Upper-case path (as an observer id) finds the same rows. + if _, body := issue199Get(t, router, strings.ToUpper(issue199InactiveObs)); body["inactive_node"] == nil { + t.Error("upper-case pubkey did not find the inactive_nodes row") + } +} + +func TestNodeDetail404ObserverWithoutNodeRecord(t *testing.T) { + srv, router := setupTestServer(t) + issue199Seed(t, srv) + + code, body := issue199Get(t, router, issue199ObsOnly) + if code != 404 { + t.Fatalf("status=%d, want 404", code) + } + if body["inactive_node"] != nil { + t.Errorf("unexpected inactive_node: %s", body["inactive_node"]) + } + var obs issue199Observer + if err := json.Unmarshal(body["observer"], &obs); err != nil || obs.Name != "Listener Only" { + t.Errorf("observer=%+v err=%v", obs, err) + } +} + +func TestNodeDetail404UnknownStaysBare(t *testing.T) { + srv, router := setupTestServer(t) + issue199Seed(t, srv) + + code, body := issue199Get(t, router, issue199Unknown) + if code != 404 || len(body) != 1 || body["error"] == nil { + t.Errorf("status=%d body=%v, want 404 with only an error field", code, body) + } +} + +// Without an inactive_nodes table (minimal schemas) the observer still counts. +func TestNodeDetail404WithoutInactiveTable(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, last_seen) VALUES (?, 'Listener Only', '2026-10-04T04:23:00Z')`, strings.ToUpper(issue199ObsOnly)); err != nil { + t.Fatal(err) + } + code, body := issue199Get(t, router, issue199ObsOnly) + if code != 404 || body["observer"] == nil || body["inactive_node"] != nil { + t.Errorf("status=%d body=%v", code, body) + } +} + +// Blacklisted or hidden identities get the bare 404: nothing about the rows leaks. +func TestNodeDetail404HiddenIdentityStaysBare(t *testing.T) { + cases := []struct { + name string + setup func(*Server) + }{ + {"node blacklist", func(s *Server) { s.cfg.SetNodeBlacklist([]string{issue199InactiveObs}) }}, + {"observer blacklist", func(s *Server) { s.cfg.ObserverBlacklist = []string{strings.ToUpper(issue199InactiveObs)} }}, + {"hidden inactive name", func(s *Server) { s.cfg.SetHiddenNamePrefixes([]string{"Quiet Rep"}) }}, + {"hidden observer name", func(s *Server) { s.cfg.SetHiddenNamePrefixes([]string{"Quiet Obs"}) }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + srv, router := setupTestServer(t) + issue199Seed(t, srv) + tc.setup(srv) + code, body := issue199Get(t, router, issue199InactiveObs) + if code != 404 || len(body) != 1 || body["error"] == nil { + t.Errorf("status=%d body=%v, want bare 404", code, body) + } + }) + } +} diff --git a/cmd/server/node_not_found.go b/cmd/server/node_not_found.go new file mode 100644 index 000000000..22b3657c9 --- /dev/null +++ b/cmd/server/node_not_found.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "log" + "net/http" + "strings" +) + +// nodeNotFoundResponse is the 404 body of GET /api/nodes/{pubkey} (#199). +// Beyond the error it carries what this instance still knows about the key, +// so the node page can explain the state instead of dead-ending on +// "Node not found": +// - InactiveNode: the inactive_nodes row, i.e. the node was retired by the +// ingestor's retention (no advert in retention.nodeDays). LastSeen is the +// last advert. +// - Observer: the observers row, when the pubkey also uploads as an +// observer (an observer may never have had a node row at all). +// +// Both are omitted for an unknown key, and for a blacklisted or hidden +// identity, whose 404 stays bare. +type nodeNotFoundResponse struct { + Error string `json:"error"` + InactiveNode *inactiveNodeInfo `json:"inactive_node,omitempty"` + Observer *observerRef `json:"observer,omitempty"` +} + +type inactiveNodeInfo struct { + PublicKey string `json:"public_key"` + Name string `json:"name"` + Role string `json:"role"` + LastSeen string `json:"last_seen"` + FirstSeen string `json:"first_seen"` +} + +type observerRef struct { + ID string `json:"id"` + Name string `json:"name"` + LastSeen string `json:"last_seen"` +} + +// lookupMissingNode builds the 404 body for a pubkey that has no nodes row: +// two read-only primary-key-sized lookups, run only on that 404 path. The +// visibility rule is identityHidden, applied to both names found here. +func (s *Server) lookupMissingNode(ctx context.Context, pubkey string) (nodeNotFoundResponse, error) { + resp := nodeNotFoundResponse{Error: "Not found"} + if s.db == nil || s.db.conn == nil { + return resp, nil + } + pk := strings.ToLower(pubkey) + + hasInactive, err := s.hasInactiveNodesTable(ctx) + if err != nil { + return resp, err + } + if hasInactive { + var n inactiveNodeInfo + err := s.db.conn.QueryRowContext(ctx, `SELECT public_key, COALESCE(name, ''), COALESCE(role, ''), COALESCE(last_seen, ''), COALESCE(first_seen, '') + FROM inactive_nodes WHERE public_key = ?`, pk).Scan(&n.PublicKey, &n.Name, &n.Role, &n.LastSeen, &n.FirstSeen) + switch { + case err == nil: + resp.InactiveNode = &n + case !errors.Is(err, sql.ErrNoRows): + return resp, err + } + } + + // Observer ids arrive raw from the MQTT topic in any case; the table is + // small, and this runs once per missing-node page view. + var o observerRef + err = s.db.conn.QueryRowContext(ctx, `SELECT id, COALESCE(name, ''), COALESCE(last_seen, '') + FROM observers WHERE lower(id) = ? ORDER BY last_seen DESC LIMIT 1`, pk).Scan(&o.ID, &o.Name, &o.LastSeen) + switch { + case err == nil: + resp.Observer = &o + case !errors.Is(err, sql.ErrNoRows): + return resp, err + } + + var names []string + if resp.InactiveNode != nil { + names = append(names, resp.InactiveNode.Name) + } + if resp.Observer != nil { + names = append(names, resp.Observer.Name) + } + if identityHidden(s.cfg, pk, names...) { + return nodeNotFoundResponse{Error: "Not found"}, nil + } + return resp, nil +} + +// writeNodeNotFound answers a node-detail miss. A failed lookup falls back to +// the bare 404: it only enriches the error and must not turn it into a 500. +func (s *Server) writeNodeNotFound(w http.ResponseWriter, r *http.Request, pubkey string) { + resp, err := s.lookupMissingNode(r.Context(), pubkey) + if err != nil { + resp = nodeNotFoundResponse{Error: "Not found"} + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("[routes] JSON encode error: %v", err) + } +} diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 0afe3f571..3f6ccb8bf 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -87,7 +87,7 @@ func routeDescriptions() map[string]routeMeta { "GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}}, "GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"}, "GET /api/nodes/network-status": {Summary: "Network status summary", Description: "Returns counts of active, stale, and offline nodes.", Tag: "nodes"}, - "GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema). recentAdverts is the chronological list; with include=advertRoutes, recentAdvertsByRoute and advertCounts (#2073) split the node's adverts into flood / zero_hop / mixed and the ADVERT rows of recentAdverts carry route_class.", Tag: "nodes", Response: schemaRef("NodeDetailResponse"), + "GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema). recentAdverts is the chronological list; with include=advertRoutes, recentAdvertsByRoute and advertCounts (#2073) split the node's adverts into flood / zero_hop / mixed and the ADVERT rows of recentAdverts carry route_class. A 404 for a key with no nodes row (#199) is {error, inactive_node?, observer?}: inactive_node {public_key, name, role, last_seen, first_seen} is the inactive_nodes row when retention retired the node (no advert in retention.nodeDays; last_seen is the last advert), observer {id, name, last_seen} is the observers row when the key uploads as an observer. Both are omitted for an unknown key and for a blacklisted or hidden identity.", Tag: "nodes", Response: schemaRef("NodeDetailResponse"), QueryParams: []paramMeta{ {Name: "include", Description: "Opt-in extras, comma-separated (the parameter may also repeat). advertRoutes (#2073): adds recentAdvertsByRoute, advertCounts and route_class on the recentAdverts ADVERT rows. That costs a scan of all the node's ADVERT rows (cached per node for up to 30 s), so only the node page asks for it; without it the response has neither field and no route_class. Unknown values are ignored. Hidden identities never get the extras.", Type: "string"}, }}, diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 30f1c5e70..e2ce37ea0 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2038,7 +2038,7 @@ func (s *Server) handleNodeDetail(w http.ResponseWriter, r *http.Request) { } } if node == nil { - writeError(w, 404, "Not found") + s.writeNodeNotFound(w, r, pubkey) return } // Hide the node when its name matches an operator-configured prefix diff --git a/public/app.js b/public/app.js index 960be42b3..425e82e35 100644 --- a/public/app.js +++ b/public/app.js @@ -195,6 +195,9 @@ async function api(path, { ttl = 0, bust = false, retry503 = true } = {}) { err.status = res.status; const ra = parseInt(res.headers.get('Retry-After'), 10); if (res.status === 503 && isFinite(ra) && ra > 0) err.retryAfterSeconds = ra; + // #199: a JSON error body travels as err.body (e.g. the node-detail + // 404's inactive_node / observer); a non-JSON body leaves it unset. + try { err.body = await res.json(); } catch (_) { /* not JSON */ } throw err; } const data = await res.json(); diff --git a/public/nodes.js b/public/nodes.js index c91de427e..2e1aa4c4e 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -696,6 +696,51 @@ return nodeData; } + /** + * #199: the node page for a key with no nodes row. The 404 body of + * /api/nodes/{pubkey} carries the key's inactive_nodes row (retired after + * retention.nodeDays without an advert) and/or its observer row. Returns + * { title, html } explaining that state, or null when the instance knows + * nothing about the key (the generic "Node not found" then stays). + */ + function missingNodeView(pubkey, notFound) { + const inactive = notFound && notFound.inactive_node; + const observer = notFound && notFound.observer; + if (!inactive && !observer) return null; + const name = (inactive && inactive.name) || (observer && observer.name) || ''; + const when = (iso) => '' + escapeHtml(formatAbsoluteTimestamp(iso)) + ''; + const rows = []; + let headline, explanation; + if (inactive) { + headline = 'Inactive node'; + explanation = 'No advert heard since ' + when(inactive.last_seen) + '; this device is inactive. ' + + 'Nodes without an advert inside the retention window are moved off the node list until they advertise again.'; + rows.push(['Name', escapeHtml(inactive.name || '—')], ['Role', escapeHtml(inactive.role || '—')], ['Last advert', when(inactive.last_seen)]); + } else { + headline = 'No node record'; + explanation = 'This device uploads as an observer, but no advert from it has been heard here, so it has no node record.'; + rows.push(['Name', escapeHtml(observer.name || '—')]); + } + if (observer) rows.push(['Last upload as observer', when(observer.last_seen)]); + const observerLink = observer + ? 'View observer →' + : ''; + const html = + '
' + explanation + '
' + + '