Lua 코드 모음

파일 그대로 복사해서 쓰는 실제 코드

전부 발췌·재구성 없이 manual-1·manual-2에서 지금 실제로 돌고 있는 .lua/.c 파일 원문 그대로입니다. 코드 상단의 "복사" 버튼으로 전체를 클립보드에 담아서, 그대로 프로젝트에 붙여넣고 config.lua에 file = "경로/파일명.lua" 한 줄만 추가하면 동작합니다. M.update()는 갱신 주기마다, M.render(p)는 화면을 그릴 때마다 호출되는 두 개의 함수만 알면 전부 읽을 수 있습니다.

코드 탐색

해결하려는 문제를 먼저 찾으세요

키워드와 목적을 고르면 관련 예제만 남습니다. 코드는 필요할 때 펼쳐서 복사하세요.

1

기본 바인딩 그대로 쓰기

가장 짧은 형태의 file= 모듈입니다. tapas.system_render()가 core의 view_system.c(view = "system"과 완전히 같은 코드)를 그대로 호출하기 때문에, 재구현이 아니라 진짜 같은 C 함수를 부르는 것입니다. 새 Lua 파일을 만들 때 항상 이 4줄(local M = {} / M.update() / M.render(p) / return M)에서 시작해서 필요한 로직만 추가해 나가면 됩니다.

시스템 상태 그대로 호출

manual-1/lua/lua_system.lua

-- "기본기능 (Lua)" 항목: 시스템 상태 — tapas.system_render()가 core의
-- view_system.c(view=system과 완전히 같은 코드)를 그대로 호출해서
-- config-only 쪽과 100% 동일한 화면을 낸다. 재구현이 아니라 진짜 같은
-- C 함수를 부르는 것.
local M = {}

function M.update()
    tapas.system_update()
end

function M.render(p)
    tapas.system_render(p)
end

return M
M.update() / M.render(p) / M.handle_key(key) 세 함수가 전체 계약입니다. update()는 화면이 보이지 않을 때도 갱신 주기마다 호출되고(이력 쌓기용), render(p)는 실제로 그릴 때만 호출됩니다(p.rows/p.cols로 가용 크기를 받음). handle_key(key)는 선택사항 — PgUp/PgDn 같은 키를 직접 처리하고 싶을 때만 정의하면 됩니다.

2 · 데이터 가공하기

파생 필드 추가하기

tapas.view_table("process")가 돌려주는 원본 결과에 Lua로 계산한 컬럼(CPU%+MEM% 합계) 하나만 얹습니다. config.lua 선언 (view=/file=)만으로는 존재하지 않는 필드를 만들어낼 수 없지만, Lua 코드 몇 줄이면 됩니다 — "선언만으로 안 되면 Lua로" 넘어가는 가장 쉬운 예시입니다. list_cols가 2 이상일 때 PgDn 스크롤 범위를 per_col(전체 행을 단 수로 나눈 몫) 기준으로 잡는 부분은, config-only SHM 구조체 뷰가 내부적으로 하는 계산과 동일합니다.

1파생 필드 추가하기

manual-2/lua/lua_step1_add_field.lua

-- [1단계] 필드 하나 추가하기 — "기본기능 (Lua)"의 프로세스 목록은 core를
-- 그대로 보여주기만 했다(재구현 없음). 여기서는 그 결과에 Lua로 계산한
-- 컬럼 하나만 얹는다 — config.lua 선언(view=/file=)만으로는 존재하지 않는
-- 필드를 만들어낼 수 없지만, Lua 코드 몇 줄이면 된다는 걸 보여주는 가장
-- 쉬운 예시.
local M = {}

function M.update() end

local scroll = 0
local last_page_size = 20
local last_total = 0

function M.render(p)
	local t = tapas.view_table("process")
	if not t then
		tapas.render_kv(p, { { name = "오류", value = "process 뷰를 찾을 수 없습니다" } })
		return
	end

	-- 원본 헤더(PID/프로세스/CPU%/MEM%/상태) 뒤에 파생 컬럼 하나만 추가한다.
	local headers = {}
	for i, h in ipairs(t.headers) do headers[i] = h end
	table.insert(headers, "CPU+MEM")

	local rows = {}
	for i, row in ipairs(t.rows) do
		local new_row = {}
		for j, cell in ipairs(row) do new_row[j] = cell end
		-- tapas.view_table()의 각 셀은 항상 { value=.., color=.. } 테이블로
		-- 온다 — CPU%(3번째 컬럼)와 MEM%(4번째 컬럼) 값을 더해서 새 컬럼으로 붙인다.
		local cpu = tonumber(row[3].value) or 0
		local mem = tonumber(row[4].value) or 0
		table.insert(new_row, string.format("%.1f", cpu + mem))
		rows[i] = new_row
	end

	-- list_cols>1이면 화면당 실제로 보이는 건 한 단(per_col)뿐이라 —
	-- widget_render_table_cols()가 내부에서 하듯 스크롤 범위도 전체 행이
	-- 아니라 per_col(= 전체를 list_cols로 나눈 몫, 올림) 기준으로 잡아야
	-- PgDn이 빈 화면 없이 끝까지 정확히 맞아떨어진다.
	local list_cols = tapas.list_cols()
	local per_col = math.ceil(#rows / list_cols)

	last_page_size = p.rows - 1
	last_total = per_col
	tapas.render_table(p, 0, headers, rows, scroll, list_cols)
end

function M.handle_key(key)
	local handled
	scroll, handled = tapas.scroll_key(key, scroll, last_total, last_page_size)
	return handled
end

return M

3 · 데이터 가공하기

조건에 따라 색 칠하기

core가 이미 정해둔 색(위험/경고) 대신 이 화면만의 기준으로 직접 색을 다시 정합니다. { value = ..., color = "danger" } 형태로 셀을 감싸면 색이 바뀝니다 — color에 쓸 수 있는 값은 "normal" | "good" | "warn" | "danger" 네 가지입니다. config.lua 선언만으로는 "이 값이 이 조건이면 이 색"이라는 판단 자체를 넣을 수 없지만, Lua는 if/elseif 몇 줄이면 됩니다.

2조건에 따라 색 칠하기

manual-2/lua/lua_step2_conditional_color.lua

-- [2단계] 조건에 따라 색 칠하기 — core가 이미 정해둔 색(위험/경고) 대신
-- 이 화면만의 기준으로 직접 색을 다시 정한다. config.lua 선언만으로는
-- "이 값이 이 조건이면 이 색"이라는 판단 자체를 넣을 수 없지만, Lua는
-- if/else 몇 줄이면 된다.
local M = {}

function M.update() end

local scroll = 0
local last_page_size = 20
local last_total = 0

-- 데모용 임계값 — 실제 운영 기준에 맞게 바꿔 쓰면 된다.
local WARN_CPU = 1.0
local DANGER_CPU = 5.0

function M.render(p)
	local t = tapas.view_table("process")
	if not t then
		tapas.render_kv(p, { { name = "오류", value = "process 뷰를 찾을 수 없습니다" } })
		return
	end

	local rows = {}
	for i, row in ipairs(t.rows) do
		local new_row = {}
		for j, cell in ipairs(row) do new_row[j] = cell end

		-- CPU%(3번째 컬럼)만 이 화면만의 임계값으로 다시 색칠한다.
		local cpu = tonumber(row[3].value) or 0
		local color = "normal"
		if cpu >= DANGER_CPU then
			color = "danger"
		elseif cpu >= WARN_CPU then
			color = "warn"
		end
		new_row[3] = { value = row[3].value, color = color }

		rows[i] = new_row
	end

	local list_cols = tapas.list_cols()
	local per_col = math.ceil(#rows / list_cols)

	last_page_size = p.rows - 1
	last_total = per_col
	tapas.render_table(p, 0, t.headers, rows, scroll, list_cols)
end

function M.handle_key(key)
	local handled
	scroll, handled = tapas.scroll_key(key, scroll, last_total, last_page_size)
	return handled
end

return M

4 · 데이터 가공하기

여러 소스 합치기

config.lua의 view=/file= 항목 하나는 소스 하나만 가리킵니다. 화면 하나에서 시스템 상태 + 메시지 큐 + 디스크를 동시에 요약하려면, 이 예제처럼 Lua에서 여러 tapas.* 호출 결과를 한 곳에 모아야 합니다 — declarative한 config.lua 선언만으로는 불가능한 조합입니다. tapas.render_kv()는 표가 아니라 키-값 목록을 그릴 때 씁니다.

3여러 소스 합치기

manual-2/lua/lua_step3_combine.lua

-- [3단계] 여러 소스 합치기 — config.lua의 view=/file= 항목 하나는 소스
-- 하나만 가리킨다. 화면 하나에서 시스템 상태 + 메시지 큐 + 디스크를 동시에
-- 요약하려면 이 화면처럼 Lua에서 여러 tapas.* 호출 결과를 한 곳에 모아야
-- 한다 — declarative한 config.lua 선언만으로는 불가능한 조합.
local M = {}

function M.update() end

function M.render(p)
	local sys = tapas.system_read()
	local queue = tapas.view_table("queue")
	local disk = tapas.view_table("disk")

	local rows = {
		{ name = "CPU 사용률", value = string.format("%d%%", sys.cpu_pct) },
		{ name = "메모리 사용률", value = string.format("%d%% (%d/%dMB)", sys.mem_pct, sys.mem_used_mb, sys.mem_total_mb) },
		{ name = "로드 애버리지", value = string.format("%.2f / %.2f / %.2f", sys.load1, sys.load5, sys.load15) },
		{ name = "메시지 큐 개수", value = tostring(queue and #queue.rows or 0) },
		{ name = "디스크 마운트 수", value = tostring(disk and #disk.rows or 0) },
	}

	tapas.render_kv(p, rows, 0, tapas.list_cols())
end

function M.handle_key(key) return false end

return M

5 · 상태·이력 프로그래밍

이력 저장해서 그래프 그리기

config.lua는 "지금 이 순간의 값"만 다룹니다. 시간에 따른 추이를 보여주려면 매 갱신마다 값을 어딘가에 쌓아 둬야 하는데, 그 "상태"를 들고 있을 곳이 필요합니다. file= 모듈은 core가 레지스트리에 캐시해서 재사용하므로, 모듈 최상위 local 변수가 매 update()/render() 호출 사이에 값을 유지합니다 — 이게 이력을 쌓을 수 있는 이유입니다.

4이력 저장해서 그래프 그리기

manual-2/lua/lua_step4_history_graph.lua

-- [4단계] 이력을 들고 있다가 그래프로 그리기 — config.lua는 "지금 이
-- 순간의 값"만 다룬다. 시간에 따른 추이를 보여주려면 매 갱신마다 값을
-- 어딘가에 쌓아 둬야 하는데, 그 "상태"를 들고 있을 곳이 필요하다.
-- file= 모듈은 core(src/view_runner.c lua_load_module)가 레지스트리에
-- 캐시해서 재사용하므로, 모듈 최상위 local 변수가 매 update()/render()
-- 호출 사이에 값을 유지한다 — 이게 이력을 쌓을 수 있는 이유다.
local M = {}

local MAX_POINTS = 60
local history_cpu = {}
local history_mem = {}

function M.update()
	local sys = tapas.system_read()
	table.insert(history_cpu, sys.cpu_pct)
	table.insert(history_mem, sys.mem_pct)
	if #history_cpu > MAX_POINTS then table.remove(history_cpu, 1) end
	if #history_mem > MAX_POINTS then table.remove(history_mem, 1) end
end

function M.render(p)
	local charts = {
		{ name = "CPU %", values = history_cpu, max = 100, warn_at = 0.6, danger_at = 0.85 },
		{ name = "MEM %", values = history_mem, max = 100, warn_at = 0.6, danger_at = 0.85 },
	}
	tapas.render_linegraph(p, 0, charts, 8, "area")
end

function M.handle_key(key) return false end

return M
인자 (charts 배열의 항목)타입필수설명
namestring필수그래프 옆에 표시할 라벨과 현재값.
valuesnumber[]필수시계열 값 배열. 오래된 값이 앞, 최신 값이 뒤(오른쪽 끝)입니다.
maxnumber선택그래프 y축 상한.
warn_at / danger_atnumber선택max 대비 비율(0~1). 넘으면 선 색이 경고/위험 색으로 바뀝니다.

6 · 상태·이력 프로그래밍

평균 대비 급등 감지 알람

1~3번 예제는 "지금 이 값"을 가져다 가공만 했고, 5번은 그 값을 시간순으로 쌓아 그래프로 그리기만 했습니다. 여기서는 그 이력을 근거로 "판단"을 직접 짭니다 — 이동평균을 계산하고, 지금 값이 평균보다 얼마나 튀었는지 보고, 몇 번 연속으로 튀었는지 세서(한 틱만 튄 건 노이즈로 보고 무시) 진짜 경보인지 스스로 결정합니다. tapas.*는 원시값(system_read)만 주고, 평균·편차·연속횟수·경보 여부라는 "로직"은 전부 이 파일이 프로그램으로 짠 것입니다.

5평균 대비 급등 감지 알람

manual-2/lua/lua_step5_anomaly_alert.lua

-- [5단계] 평균 대비 급등 감지 알람 — 1~3단계는 "지금 이 값"을 가져다
-- 가공만 했고, 4단계는 그 값을 시간순으로 쌓아 그래프로 그리기만 했다.
-- 여기서는 그 이력을 근거로 "판단"을 직접 짠다 — 이동평균을 계산하고,
-- 지금 값이 평균보다 얼마나 튀었는지 보고, 몇 번 연속으로 튀었는지 세서
-- (한 틱만 튄 건 노이즈로 보고 무시) 진짜 경보인지 스스로 결정한다.
-- tapas.*는 원시값(system_read)만 준다 — 평균/편차/연속횟수/경보 여부라는
-- "로직"은 전부 이 파일이 프로그램으로 짠 것이다.
local M = {}

local MAX_POINTS = 30
local SPIKE_RATIO = 1.5    -- 이동평균보다 이 배수 이상이면 "튐" 후보
local MIN_BASELINE = 5     -- 평균이 너무 낮으면(0에 가까우면) 배수 비교가 과민해지므로 무시
local ALERT_AFTER = 3      -- 연속 이만큼 튀어야 진짜 경보로 취급(순간 튐은 무시)

local history = {}
local streak = 0

function M.update()
	local sys = tapas.system_read()
	table.insert(history, sys.cpu_pct)
	if #history > MAX_POINTS then table.remove(history, 1) end
end

function M.render(p)
	local cur = history[#history] or 0
	local sum = 0
	for _, v in ipairs(history) do sum = sum + v end
	local avg = #history > 0 and (sum / #history) or 0

	local spiking = avg >= MIN_BASELINE and cur >= avg * SPIKE_RATIO
	streak = spiking and (streak + 1) or 0

	local status, color
	if streak >= ALERT_AFTER then
		status, color = "ALERT", "danger"
	elseif spiking then
		status, color = "감시중", "warn"
	else
		status, color = "정상", "good"
	end

	local dev_pct = avg > 0 and ((cur - avg) / avg * 100) or 0

	tapas.render_table(p, 0,
		{ "현재 CPU%", "이동평균(30틱)", "평균 대비", "연속 초과", "상태" },
		{ {
			string.format("%.1f", cur),
			string.format("%.1f", avg),
			string.format("%+.0f%%", dev_pct),
			tostring(streak),
			{ value = status, color = color },
		} })
end

function M.handle_key(key) return false end

return M

7

다중 SHM 조인

여러 SHM에 흩어진 항목을 msg_code 기준으로 합쳐서 하나의 표로 보여줍니다. 실무에서 흔한 경우 — 수신/처리/DB적재가 서로 다른 프로세스라 SHM도 각자 따로 관리합니다. SHM 구조체 뷰 엔진은 SHM 하나만 다루도록 설계돼 있어서 이런 조인은 config.lua(view=)만으로는 안 되고 Lua 파일(file=)이 필요합니다.

API설명
tapas.shmstruct_read(spec)SHM 하나를 읽어 레코드 배열을 Lua 테이블로 그대로 돌려주는 개발자용 raw 조회 API. 화면에 안 그리고 데이터만 가져옵니다(필드 값은 전부 문자열).
tapas.render_table(p, row_offset, headers, rows, scroll, list_cols)아무 데이터나 표로 그려주는 범용 렌더 함수. 셀을 { value=, color=, align= }로 주면 정렬·색을 지정할 수 있습니다.

전문별 처리 건수 (SHM 3개 조인)

manual-1/lua/lua_msgjoin.lua

-- 여러 SHM에 흩어진 항목을 msg_code 기준으로 합쳐서 하나의 표로 보여준다.
-- 실무에서 흔한 경우: 수신/처리/DB적재가 서로 다른 프로세스라 SHM도 각자
-- 따로 관리한다 — SHM 구조체 뷰 엔진(view_shmstruct.c)은 SHM 하나만
-- 다루도록 설계돼 있어서(전문별 처리 건수 예시 참고), 이런 조인은
-- config.lua(view=)만으로는 안 되고 Lua 파일(file=)이 필요하다.
local M = {}

local RECV_SPEC = {
	shm_key = 0x1301,
	header  = "examples/msg_recv_stats.h",
	struct  = "RECV_STATS.data",
	header_struct = "RECV_STATS_HEADER",
	count_field = "cur_cnt",
}
local PROC_SPEC = {
	shm_key = 0x1302,
	header  = "examples/msg_proc_stats.h",
	struct  = "PROC_STATS.data",
	header_struct = "PROC_STATS_HEADER",
	count_field = "cur_cnt",
}
local DB_SPEC = {
	shm_key = 0x1303,
	header  = "examples/msg_db_stats.h",
	struct  = "DB_STATS.data",
	header_struct = "DB_STATS_HEADER",
	count_field = "cur_cnt",
}

function M.update()
	-- render()에서 매 프레임 세 SHM을 새로 읽으므로 별도 갱신 불필요
end

function M.render(p)
	local recv = tapas.shmstruct_read(RECV_SPEC)
	local proc = tapas.shmstruct_read(PROC_SPEC)
	local db   = tapas.shmstruct_read(DB_SPEC)

	-- msg_code로 세 SHM을 합친다 (recv를 기준으로 시작해 처음 보는
	-- 코드가 나올 때마다 순서를 기록 — 등장 순서를 보존하기 위함).
	local by_code, order = {}, {}
	local function row_for(code)
		if not by_code[code] then
			by_code[code] = { recv_cnt = "0", proc_cnt = "0", db_cnt = "0" }
			table.insert(order, code)
		end
		return by_code[code]
	end
	for _, r in ipairs(recv) do row_for(r.msg_code).recv_cnt = r.recv_cnt end
	for _, r in ipairs(proc) do row_for(r.msg_code).proc_cnt = r.proc_cnt end
	for _, r in ipairs(db)   do row_for(r.msg_code).db_cnt   = r.db_cnt   end

	local rows = {}
	for _, code in ipairs(order) do
		local v = by_code[code]
		table.insert(rows, {
			code,
			{ value = v.recv_cnt, align = "right" },
			{ value = v.proc_cnt, align = "right" },
			{ value = v.db_cnt,   align = "right" },
		})
	end

	tapas.render_table(p, 1,
		{ "전문코드", "수신건수 (SHM_A)", "처리건수 (SHM_B)", "DB저장건수 (SHM_C)" },
		rows, 0, tapas.list_cols())  -- config.lua의 list_cols=N을 그대로 따른다
end

return M
일부 SHM에 없는 코드(예: 실시간 시세라 DB에 저장 안 하는 항목, 방금 추가돼 아직 처리단에 안 반영된 신규 전문)도 row_for()가 기본값 "0"으로 안전하게 채워서 표시합니다.
같은 조합(shmstruct_read() + render_table())은 조인 말고도 씁니다 — SHM에 코드만 있고 이름이 없을 때, 코드→이름 매핑을 Lua 배열로 붙여서 보여주는 경우(manual-1/lua/lua_msgstats.lua 참고). shmstruct_render는 파싱한 필드만 그대로 보여줄 뿐 없는 값을 만들 순 없어서 이럴 땐 못 씁니다.

8

DB 조회 (MySQL / PostgreSQL / Altibase)

DB 클라이언트 라이브러리를 링크하지 않습니다 — mysql/psql/ isql 표준 CLI를 io.popen()으로 실행해 출력을 표로 파싱합니다. db_common.lua가 공용 골격, DB별 파일은 접속정보·쿼리만 다릅니다. 자세한 접속 설정은 매뉴얼 참고.

db_mysql.lua — 접속정보/쿼리

lua/db/db_mysql.lua

-- MySQL 조회 뷰: mysql CLI를 io.popen()으로 실행해 결과를 표로 표시.
-- host에는 mysql CLI를 설치하지 않고, docker 컨테이너 안의 mysql 클라이언트를
-- `docker exec`로 그대로 호출한다. 접속정보/쿼리는 아래 값만 고치면 됨
-- (재빌드 불필요). 비밀번호는 커맨드라인 인자 대신 MYSQL_PWD 환경변수로
-- 넘겨 `ps aux` 노출을 막는다(단 /proc/pid/environ로는 여전히 보일 수 있음).
local common = dofile("lua/db/db_common.lua")

local container = "tapas-eye-demo-mysql"
local user       = "root"
local password   = ""
local database   = "test"
local query      = "SELECT isin, exec_prc, exec_qty, open_prc, high_prc, low_prc FROM koscom_stock_a3_test;"

local function build_cmd()
    return string.format(
        "docker exec -e MYSQL_PWD=%s %s mysql -u%s %s -B -e %s",
        common.shell_quote(password),
        common.shell_quote(container),
        common.shell_quote(user),
        common.shell_quote(database),
        common.shell_quote(query)
    )
end

return common.new_view({ build_cmd = build_cmd, sep = "\t", refresh_sec = 5 })

db_postgresql.lua — 접속정보/쿼리

lua/db/db_postgresql.lua — host에 이미 있는 psql로 TCP 직접 접속

-- PostgreSQL 조회 뷰: psql CLI를 io.popen()으로 실행해 결과를 표로 표시.
-- 접속정보/쿼리는 아래 값만 고치면 됨 (재빌드 불필요). 비밀번호는 커맨드라인
-- 인자 대신 PGPASSWORD 환경변수로 넘긴다 — 더 안전한 대안은 ~/.pgpass.
--
-- 예제 쿼리는 examples/koscom_stock.h의 KOSCOM_STOCK_A0(코스피/코스닥/코넥스
-- 배치) 구조체 필드 중 일부(종목코드/한글약명/기준가/전일종가/상한가/하한가)를
-- 그대로 옮긴 테스트 테이블 koscom_stock_a0_test를 조회한다. 테이블/샘플
-- 데이터는 lua/db/db_postgresql_seed.sql로 만든다.
local common = dofile("lua/db/db_common.lua")

local host     = "127.0.0.1"
local port     = 5432
local user     = "postgres"
local password = ""
local database = "postgres"
local query    = "SELECT isin, kor_name, base_price, prev_close, upper_limit, lower_limit FROM koscom_stock_a0_test;"

local function build_cmd()
    return string.format(
        "PGPASSWORD=%s psql -h %s -p %s -U %s -d %s -A -F %s -c %s",
        common.shell_quote(password),
        common.shell_quote(host),
        tostring(port),
        common.shell_quote(user),
        common.shell_quote(database),
        common.shell_quote("|"),
        common.shell_quote(query)
    )
end

return common.new_view({ build_cmd = build_cmd, sep = "|", refresh_sec = 5 })

db_altibase.lua — 접속정보/쿼리

lua/db/db_altibase.lua — isql은 구분자 출력이 없어 우회 로직 포함

-- Altibase 조회 뷰: isql CLI를 io.popen()으로 실행해 결과를 표로 표시.
-- host에는 아무것도 설치하지 않고, docker 컨테이너(tapas-eye-demo-altibase,
-- altibase/a_plus_edition — 라이선스 없이 도는 무료 에디션) 안의 isql을
-- `docker exec`로 그대로 호출한다 — db_mysql.lua와 같은 패턴.
--
-- isql은 mysql -B / psql -A 같은 "구분자 출력" 옵션이 없다(고정폭 표 +
-- 헤더밑줄 + "N rows selected." 꼬리말만 지원). 그래서 두 가지로 우회한다:
--   1. `set heading off;`로 isql 자체 헤더/밑줄을 끄고, 진짜 헤더는 우리가
--      echo로 직접 앞에 붙인다.
--   2. 쿼리 자체에서 SELECT ... || '|' || ... 로 각 행을 이미 구분자로 이어
--      붙여서 내보낸다 — isql이 남기는 컬럼 우측 공백 패딩은 sed로 벗겨낸다.
-- 결과물은 mysql/psql이 내는 것과 같은 모양(헤더 줄 + 구분자로 나뉜 데이터
-- 줄 + "N rows selected.")이 되어 db_common.lua를 그대로 재사용할 수 있다.
--
-- 예제 쿼리는 examples/msg_db_stats.h의 DB_STATS_DATA(DB적재 프로세스가
-- 전문별로 남기는 DB저장 건수) 구조체 필드(전문코드/DB저장건수)를 그대로
-- 옮긴 테스트 테이블 msg_db_stats_test를 조회한다. 테이블/샘플 데이터는
-- lua/db/db_altibase_seed.sql로 만든다.
local common = dofile("lua/db/db_common.lua")

local container = "tapas-eye-demo-altibase"
local user       = "sys"
local password   = "manager"
local headers    = { "msg_code", "db_cnt" }
local query      = "SELECT msg_code || '|' || db_cnt FROM msg_db_stats_test;"

local function build_cmd()
    local isql_cmd = string.format(
        "$ALTIBASE_HOME/bin/isql -u %s -p %s -s localhost -silent -noprompt",
        user, password
    )
    return string.format(
        "{\necho %s\ndocker exec -i %s bash -lc %s <<'ALTIBASE_SQL'\nset heading off;\n%s\nquit;\nALTIBASE_SQL\n} | sed 's/[ \t]*$//'",
        common.shell_quote(table.concat(headers, "|")),
        common.shell_quote(container),
        common.shell_quote(isql_cmd),
        query
    )
end

return common.new_view({ build_cmd = build_cmd, sep = "|", refresh_sec = 5 })

db_common.lua — 공용 골격 (그대로 재사용)

lua/db/db_common.lua — CLI 실행·파싱·주기적 재조회를 전부 담당

-- DB 조회 공용 라이브러리. 각 DB의 CLI 클라이언트(mysql/psql/isql)를
-- io.popen()으로 실행해 stdout을 구분자 기반 표로 파싱, tapas.render_table()로
-- 표시한다. 새 C API나 재빌드 없이 db_*.lua 상단의 접속정보/쿼리만 고치면 된다.
local M = {}

-- 셸에 안전하게 넘기기 위한 작은따옴표 이스케이프 (셸 인젝션 방지)
function M.shell_quote(s)
    return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end

-- cmd를 실행해 stdout+stderr를 합쳐서 읽는다.
-- 성공: out, nil / 실패: nil, err(명령이 stderr에 남긴 메시지)
function M.run_capture(cmd)
    local f = io.popen(cmd .. " 2>&1")
    if not f then
        return nil, "명령 실행 실패: io.popen 실패"
    end
    local out = f:read("*a") or ""
    local ok = f:close()
    if not ok then
        local msg = out:gsub("%s+$", "")
        if msg == "" then msg = "명령 실행 실패 (종료 코드 비정상)" end
        return nil, msg
    end
    return out, nil
end

-- DB 클라이언트가 결과 뒤에 붙이는 꼬리말 줄(건수 요약 등)은 클라이언트
-- 종류/버전마다 문구가 달라서 고정 줄수 대신 패턴으로 걸러낸다.
local FOOTER_PATTERNS = {
    "^%(%d+ rows?%)%s*$",       -- psql -A
    "^%d+ rows? in set",        -- mysql
    "^%d+ rows? selected%.?%s*$", -- 여러 DB의 일반적인 "N rows selected."
}

local function is_footer(line)
    for _, pat in ipairs(FOOTER_PATTERNS) do
        if line:match(pat) then return true end
    end
    return false
end

-- 구분자 기반 표 출력(첫 줄=헤더)을 { columns }, { rows... }로 파싱.
-- 빈 줄/꼬리말 줄은 무시.
function M.parse_delimited(output, sep)
    local columns, rows = nil, {}
    for line in (output .. "\n"):gmatch("(.-)\n") do
        if line ~= "" and not is_footer(line) then
            local fields = {}
            for field in (line .. sep):gmatch("(.-)" .. sep) do
                fields[#fields + 1] = field
            end
            if not columns then
                columns = fields
            else
                rows[#rows + 1] = fields
            end
        end
    end
    return columns or {}, rows
end

-- opts.build_cmd() : 실행할 셸 커맨드 문자열을 매번 새로 만들어 반환
-- opts.sep         : 출력 구분자 (mysql -B는 "\t", psql -A -F는 "|" 등)
-- opts.refresh_sec : 재조회 주기(초), 기본 5 — 화면 갱신 틱마다가 아니라
--                    이 주기마다 한 번만 쿼리 실행(DB 연결 폭주 방지)
function M.new_view(opts)
    local V = {}
    local scroll = 0
    local page_size = 20
    local columns, rows = {}, {}
    local err = nil
    local last_refresh = 0
    local refresh_sec = opts.refresh_sec or 5

    local function refresh()
        local out, e = M.run_capture(opts.build_cmd())
        if e then
            err = e
            columns, rows = {}, {}
        else
            err = nil
            columns, rows = M.parse_delimited(out, opts.sep or "\t")
        end
    end

    function V.update()
        local now = os.time()
        if now - last_refresh >= refresh_sec then
            last_refresh = now
            refresh()
        end
    end

    function V.render(p)
        page_size = math.max(1, p.rows - 1)
        if err then
            tapas.render_table(p, 0, { "오류" }, {
                { { value = err:gsub("\n", " "), color = "danger" } },
            }, 0)
            return
        end
        if #columns == 0 then
            tapas.render_table(p, 0, { "상태" }, { { "데이터 없음" } }, 0)
            return
        end
        tapas.render_table(p, 0, columns, rows, scroll)
    end

    function V.handle_key(key)
        local new_scroll, handled = tapas.scroll_key(key, scroll, #rows, page_size)
        scroll = new_scroll
        return handled
    end

    return V
end

return M

9

대시보드 구성

update()/render() 없이 panels = {...} 테이블만 반환하면, config.lua 선언 쪽(panels = 필드)과 완전히 같은 C 엔진(view_dashboard.c)이 그립니다 — 재구현이 아니라 같은 코드 경로를 타는 것이라 출력이 항상 100% 동일합니다. "Lua 파일에 로직을 다시 짜지 않고 선언만 옮겨 적어도 똑같이 동작한다"는 원칙을 가장 압축적으로 보여주는 예시입니다.

종합 현황 (Lua)

manual-1/lua/lua_dashboard.lua

-- "대시보드" 메뉴의 Lua 버전 — 왼쪽 config-only 항목(관리자가 config.lua
-- 메뉴 항목에 panels = {...}를 직접 적는 것)과 똑같은 3개 화면(시스템 상태/
-- 프로세스 목록/로그)을 나열한다.
--
-- update()/render() 같은 로직은 전혀 없다 — 이 파일이 하는 일은 config.lua의
-- panels = {...} 문법을 그대로 Lua 파일 하나에 옮겨 적는 것뿐이다.
return {
	panels = {
		{ label = "시스템 상태", view = "system" },
		{ label = "프로세스 목록", view = "process" },
		{ label = "로그", view = "log" },
		{
			label = "SHM 구조체",
			view = "manual_shmstruct_dashboard",
			shm_key = 0x1234,
			header = "examples/koscom_stock.h",
			struct = "KOSCOM_STOCK_A0",
			start_index = 3,
			orientation = "vertical",
			vertical_rows = 3,
		},
	},
}

10

C로 확장하기

tapas-eye의 진짜 강점입니다: Lua에도 없는 기능은 C 함수 하나를 짜서 끝냅니다. statvfs()는 표준 Lua(os/io)에 없는 시스템 콜이라 순수 Lua로는 흉내조차 낼 수 없습니다 — 이런 건 진짜로 C가 아니면 안 되는 지점이고, 그 지점만 C로 짜고 나머지(화면 배치, 반복, 포맷팅)는 여전히 Lua로 짜는 게 tapas-eye 확장의 기본 패턴입니다. 컴파일은 plugins/build.sh disk_free 한 줄이면 되고, 탑레벨 Makefile이나 전체 소스 재빌드가 필요 없습니다.

플러그인은 두 패턴으로 나뉩니다 — 패턴 A(값만 반환)는 C가 계산만 하고 화면은 Lua의 tapas.render_*()가 그리므로 SSH·웹 양쪽에서 동작하고, 패턴 B(직접 그리기)는 C가 tui_*로 직접 ANSI를 그려서 빠르지만 SSH 전용입니다(이유는 아래 패턴 B 항목 참고). 아래 disk_free.c/msgq_depth.c가 패턴 A, example_view.c가 패턴 B입니다.

plugins/disk_free.c — 패턴 A: 값만 반환

plugins/disk_free.c

/*
 * 특정 경로의 실제 여유 공간(GB)을 statvfs()로 직접 조회하는 라이브러리
 * 플러그인. Lua 표준 라이브러리(os/io)에는 이런 시스템 콜이 없어서 순수
 * Lua로는 만들 수 없다 — C로 짜서 require()로 불러 쓰는 게 tapas-eye
 * 확장의 핵심 패턴이다.
 *
 * 컴파일: plugins/build.sh disk_free
 * Lua에서 사용:
 *   local disk_free = require("disk_free")
 *   local gb = disk_free.gb("/")  -- 경로가 없으면 nil
 */
#include "tapas_plugin.h"
#include <sys/statvfs.h>

static int l_gb(lua_State *L) {
    const char *path = luaL_checkstring(L, 1);

    struct statvfs st;
    if (statvfs(path, &st) != 0) {
        lua_pushnil(L);
        return 1;
    }

    double free_bytes = (double)st.f_bsize * (double)st.f_bavail;
    lua_pushnumber(L, free_bytes / (1024.0 * 1024.0 * 1024.0));
    return 1;
}

static const luaL_Reg funcs[] = {
    {"gb", l_gb},
    {NULL, NULL}
};

int luaopen_disk_free(lua_State *L) {
    luaL_newlib(L, funcs);
    return 1;
}

6Lua에서 사용

manual-2/lua/lua_step6_c_library.lua

-- require()로 불러오면 그 뒤로는 평범한 Lua 함수처럼 쓴다.
-- (plugins/*.so는 require()가 자동으로 찾는다 — package.cpath가
-- './plugins/?.so'를 이미 포함하도록 lua_conf.c가 설정해둔다.)
local disk_free = require("disk_free")

local M = {}

local PATHS = { "/", "/tmp", "/var" }

function M.update() end

function M.render(p)
	local rows = {}
	for _, path in ipairs(PATHS) do
		local gb = disk_free.gb(path)
		table.insert(rows, {
			name = path,
			value = gb and string.format("%.1f GB 여유", gb) or "경로 없음 (statvfs 실패)",
		})
	end
	tapas.render_kv(p, rows)
end

function M.handle_key(key) return false end

return M

plugins/example_view.c — 패턴 B: 직접 그리기 (SSH 전용)

tapas.render_*()를 거치지 않고 tui_move()/tui_puts()로 직접 ANSI를 그린다

#include "tapas_plugin.h"
#include <time.h>

static char s_time[32];
static int  s_tick = 0;

static int l_update(lua_State *L) {
    (void)L;
    time_t t = time(NULL);
    strftime(s_time, sizeof(s_time), "%Y-%m-%d %H:%M:%S", localtime(&t));
    s_tick++;
    return 0;
}

static int l_render(lua_State *L) {
    TapasPanel p = tapas_panel_from_lua(L, 1);

    tui_move(p.row + 1, p.col + 2);
    tui_color(TAPAS_CLR_HEADER);
    tui_puts("[ Example Plugin v1.0 ]");
    tui_clear_line();

    tui_move(p.row + 4, p.col + 4);
    tui_color(TAPAS_CLR_NORMAL);
    tui_printf("현재 시각  : %s", s_time);
    tui_clear_line();

    tui_color(TAPAS_CLR_RESET);
    return 0;
}

static const luaL_Reg funcs[] = {
    {"update", l_update}, {"render", l_render}, {NULL, NULL}
};

int luaopen_example_view(lua_State *L) {
    luaL_newlib(L, funcs);
    return 1;
}
패턴 B가 SSH 전용인 이유: tapas.render_table()/render_kv() 같은 Lua 바인딩만 SSH(ANSI)/웹(JSON) 출력을 알아서 갈라 그립니다. C 플러그인이 그 바인딩을 거치지 않고 tui_move()/tui_puts()를 직접 부르면 그 분기 자체가 없어서, 웹 대시보드는 이 패널에 대한 JSON을 아예 못 받아 빈 화면으로 보입니다. 웹에서도 써야 하면 패턴 A(값만 반환)로 짜세요.

plugins/msgq_depth.c — 패턴 A, 실전 사용 중

scenarios/ab-pipeline이 실제로 쓰는 플러그인 — msgctl(IPC_STAT)으로 큐 대기 건수 직접 조회

#include "tapas_plugin.h"
#include <sys/ipc.h>
#include <sys/msg.h>

static int l_read(lua_State *L) {
    long key = (long)luaL_checkinteger(L, 1);

    int msqid = msgget((key_t)key, 0);
    if (msqid < 0) { lua_pushnil(L); return 1; }

    struct msqid_ds ds;
    if (msgctl(msqid, IPC_STAT, &ds) != 0) { lua_pushnil(L); return 1; }

    lua_pushinteger(L, (lua_Integer)ds.msg_qnum);
    return 1;
}

static const luaL_Reg funcs[] = { {"read", l_read}, {NULL, NULL} };

int luaopen_msgq_depth(lua_State *L) {
    luaL_newlib(L, funcs);
    return 1;
}

/proc/sysvipc/msg 텍스트를 파싱하는 대신 커널이 이미 추적 중인 msg_qnum을 msgget()+msgctl(IPC_STAT)으로 직접 읽습니다 — scenarios/ab-pipeline/lua/view_ab_queue.lua가 SHM(tapas.shmstruct_read) + 파일(io.open) + 이 플러그인 세 소스를 한 행에 조합해서 씁니다.

luaopen_<name> 규약이 핵심입니다 — 표준 Lua C 모듈 규약 그대로, tapas.* API 자체도 이 방법으로 만들어져 있습니다.
  • 이름 연결: disk_free.c → disk_free.so, require("disk_free")가 내부적으로 luaopen_disk_free를 찾아 호출.
  • 함수 시그니처: 전부 static int l_xxx(lua_State *L) 하나뿐 — 반환값은 lua_push*()로 쌓고 그 개수를 return.
  • 링크: tapas-eye 바이너리가 -rdynamic으로 자기 심볼을 미리 내보내 둬서 .so가 tui_*를 몰라도 dlopen() 시점에 자동 연결. require()는 package.cpath로 .so를 자동으로 찾으므로 config.lua는 건드릴 필요 없음.

전체 맥락이 궁금하다면

여기 있는 코드가 config.lua 선언에서 시작해 어떻게 여기까지 왔는지, 단계별 흐름은 상세 매뉴얼에서 볼 수 있습니다.

상세 매뉴얼 보러 가기